From 8c97788682d395762d679ab318d25a3ca8e57271 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 16:43:06 +0000 Subject: [PATCH] Replace legacy daily report with generated text daily report --- docs/cli.md | 2 +- docs/config.md | 10 +- docs/internal/briefing.md | 8 +- docs/internal/module.md | 24 ++++- docs/internal/prompt-input.md | 15 +-- docs/internal/report-registry.md | 31 +++--- docs/operations.md | 2 +- internal/app/app.go | 2 +- internal/app/app_test.go | 97 +++++++++++++------ internal/briefing/base_modules_test.go | 4 +- internal/briefing/derived_modules_test.go | 18 ++-- internal/briefing/modules.go | 13 ++- internal/briefing/modules_test.go | 18 ++-- internal/briefing/package.go | 2 +- internal/cli/root_test.go | 42 ++++++-- internal/config/config_test.go | 16 +-- internal/facts/facts.go | 2 +- internal/facts/facts_test.go | 12 ++- internal/generatedtext/catalog_test.go | 8 +- internal/generatedtext/render_context_test.go | 6 +- internal/promptinput/package.go | 1 + internal/promptinput/package_test.go | 22 ++--- internal/report/daily_report.go | 40 ++++---- internal/report/definition.go | 14 +-- internal/report/names.go | 6 +- internal/report/period_test.go | 72 +++++++++----- internal/report/registry.go | 4 +- internal/state/filesystem_test.go | 29 ++++-- 28 files changed, 326 insertions(+), 194 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f4a5ca6..7d8266b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -110,7 +110,7 @@ weatherreporter inspect modules 20260529T100000.000000000Z_today weatherreporter inspect data-package 20260529T100000.000000000Z_today weatherreporter inspect prior 20260529T100000.000000000Z_today weatherreporter inspect sources 20260529T100000.000000000Z_today -weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today +weatherreporter inspect metadata 20260529T100000.000000000Z_daily ``` `inspect reports` lists recent generated runs with artifact paths and source diff --git a/docs/config.md b/docs/config.md index 5a27ae7..7fac128 100644 --- a/docs/config.md +++ b/docs/config.md @@ -183,12 +183,12 @@ report definitions. Omit a report entry to use its default module order. Supported report keys are `daily`, `today`, `tomorrow`, `hourly`, `three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases -are also valid, including `daily_today`, `three_day_outlook`, -`weekend_outlook`, and `storm_report`. Hyphens and underscores are treated -equivalently in report keys. +are also valid, including `three_day_outlook`, `weekend_outlook`, and +`storm_report`. Hyphens and underscores are treated equivalently in report +keys. `daily_today` is not a supported report key. -`reports.today` applies only to the Today Report. `reports.daily` and -`reports.daily_today` apply only to the existing Daily Today report. +`reports.today` applies only to the Today Report. `reports.daily` applies only +to the dated Daily Report. Each report entry supports: diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 5fd207a..484cfa7 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -39,6 +39,10 @@ Outputs: Every registered composition entry has a builder. Unknown or unimplemented module IDs fail validation instead of being skipped. +Daily Report supports the Daily-style civil-day modules plus `daily_planning` +and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt +package and embedded Markdown template. + Tomorrow Report supports the Daily-style civil-day modules plus `tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow GeneratedText prompt package and embedded Markdown template. @@ -53,8 +57,8 @@ late-day change-watch fields. It is compatible with `report.Today` only. `daily_planning` is a dated Daily deterministic planning stanza with morning readiness, commute/school/workday concerns, and overnight change-watch fields. -It is compatible only with the `daily` report ID value. No active default -report composition includes it yet. +It is compatible only with the `daily` report ID value. The default Daily +Report composition includes it. Hourly Report supports source and valid-period modules that operate over its rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`, diff --git a/docs/internal/module.md b/docs/internal/module.md index e53489e..d56d4de 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -49,6 +49,28 @@ The registry recognizes these IDs: Every registered module has a builder. Report composition entries that refer to unknown or unimplemented module IDs fail validation instead of being skipped. +## Daily Composition + +The default Daily Report module order is: + +1. `metadata` +2. `current_conditions` +3. `narrative_forecast` +4. `derived_daily_summary` +5. `derived_daypart_summaries` +6. `precip_timing` +7. `alert_digest` +8. `spc_convective_outlooks` +9. `area_forecast_discussion` +10. `spc_convective_discussion` +11. `weather_story` +12. `outdoor_windows` +13. `daily_planning` +14. `hourly_forecast` + +The embedded Daily template uses selected deterministic fields from these +module outputs after GeneratedText validation. + ## Today Composition The default Today Report module order is: @@ -98,7 +120,7 @@ module outputs after GeneratedText validation. `daily_planning` emits dated daily planning facts for the `daily` report ID. Its output stanza is also named `daily_planning`. The module is supported only by that report ID and depends on daily summaries for the selected local civil -day. No active default report composition includes it yet. +day. The default Daily Report composition includes it. The output uses this shape: diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 4421ee5..d4427ec 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -68,9 +68,9 @@ Prompt-facing module intervals use local `period_begins` and `period_ends` labels; canonical report metadata and source timestamps remain structured timestamps where applicable. -Today Report, Tomorrow Report, and Hourly Report module snapshots use the same -package schema and categories when converted into prompt input. The default -hourly module list places +Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots +use the same package schema and categories when converted into prompt input. +The default hourly module list places `precip_timing` under `derived_summaries`, alert and SPC outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story under `narrative_products`, and current/hourly data under `raw_data`. It does not @@ -78,10 +78,11 @@ include civil-day summary stanzas. Generated-text and render context artifacts are produced later in app orchestration and are not part of the YAML data package. -The default Today and Tomorrow module lists include civil-day summary stanzas, -planning stanzas, and `hourly_forecast` in the data package before structured -GeneratedText is requested from Scriptorium. Today uses `today_planning`; -Tomorrow uses `tomorrow_planning`. +The default Daily, Today, and Tomorrow module lists include civil-day summary +stanzas, planning stanzas, and `hourly_forecast` in the data package before +structured GeneratedText is requested from Scriptorium. Daily uses +`daily_planning`, Today uses `today_planning`, and Tomorrow uses +`tomorrow_planning`. Current categories are: diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index 02baeae..b58e6fe 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -29,25 +29,25 @@ Each report definition declares: Report-owned helpers map public command names and config keys to report IDs. The generate command names are `daily`, `today`, `tomorrow`, `hourly`, -`three-day`, `weekend`, and `storm`. Config keys also accept underscore and -legacy descriptive aliases such as `daily_today`, `three_day_outlook`, +`three-day`, `weekend`, and `storm`. Config keys also accept selected +underscore and descriptive aliases such as `three_day_outlook`, `weekend_outlook`, and `storm_report`. -`daily` resolves to the existing Daily Today report ID `daily_today`. -`today` resolves to the independent Today report ID `today`. `reports.today` -is not an alias for `reports.daily` or `reports.daily_today`. +`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the +independent Today report ID `today`. `reports.today` is not an alias for +`reports.daily`, and `reports.daily_today` is not a supported config key. Markdown report definitions use the `scriptorium_markdown` generation mode. -Their template and structured-text schema identifiers are empty. Today Report, -Tomorrow Report, and Hourly Report declare `generated_text_template`; the app -uses their template and schema identifiers to validate generated text and render -embedded Markdown templates. +Their template and structured-text schema identifiers are empty. Daily Report, +Today Report, Tomorrow Report, and Hourly Report declare +`generated_text_template`; the app uses their template and schema identifiers +to validate generated text and render embedded Markdown templates. ## Reports | Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility | | --- | --- | --- | --- | --- | --- | --- | -| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today | +| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report | | Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report | | Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report | @@ -59,8 +59,8 @@ All report definitions are eligible for generation. ## Valid Periods -- Daily Today covers the selected local civil day, or the current local civil - day when no date override is supplied. +- Daily Report covers the selected local civil day and requires an explicit + date. - Today Report covers the selected local civil day, or the current local civil day when no date override is supplied. - Tomorrow Report covers the next local civil day from generation time. @@ -91,14 +91,13 @@ report override keys. The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch output path copying uses batch output names from report definitions. Report -module overrides can use short keys such as `today`, `tomorrow`, and `hourly`, -canonical report IDs such as `daily_today`, or descriptive names such as -`three_day_outlook`. +module overrides can use short keys such as `daily`, `today`, `tomorrow`, and +`hourly`, or descriptive names such as `three_day_outlook`. ## Batch Membership Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook -except on Sunday. Evening batches include Tomorrow Report. Daily Today and +except on Sunday. Evening batches include Tomorrow Report. Daily Report and Hourly Report are not part of a scheduled batch. ## State And App Usage diff --git a/docs/operations.md b/docs/operations.md index eb159e3..ba1893a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -196,7 +196,7 @@ period do not overwrite each other. RunIDs are based on generation time plus report ID: ```text -20260529T100000.123456789Z_daily_today +20260529T100000.123456789Z_daily 20260529T100000.123456789Z_today ``` diff --git a/internal/app/app.go b/internal/app/app.go index be2a307..690adeb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1107,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state. PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, } switch reportID { - case report.DailyToday, report.Today, report.Tomorrow: + case report.Daily, report.Today, report.Tomorrow: return changes.CompareDaily(previous, current, thresholds) case report.ThreeDay: return changes.CompareThreeDay(previous, current, thresholds) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6c1dd72..fcbd11e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -105,6 +105,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { ExitCode: 0, OutputPath: "", }, + structuredRunResult: &scriptorium.StructuredRunResult{ + Command: []string{"scriptorium", "run"}, + Stderr: "wrote generated text", + ExitCode: 0, + }, runBody: "# Daily Report\n\nRain this morning.\n", } outputPath := filepath.Join(t.TempDir(), "daily.md") @@ -128,25 +133,28 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if renderer.renderCalls != 1 { t.Fatalf("render calls = %d, want 1", renderer.renderCalls) } - if renderer.runCalls != 1 { - t.Fatalf("run calls = %d, want 1", renderer.runCalls) + if renderer.structuredRunCalls != 1 { + t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls) } - if renderer.renderRequest.PromptID != "weather.daily_report" { - t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID) + if renderer.runCalls != 0 { + t.Fatalf("markdown run calls = %d, want none", renderer.runCalls) + } + if renderer.renderRequest.PromptID != "weather.daily_generated_text" { + t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID) } if renderer.renderRequest.DataPackagePath != result.DataPackagePath { t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath) } - if renderer.runRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath) + if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath { + t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath) } - if renderer.runRequest.OutputPath != result.ReportPath { - t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) + if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath { + t.Fatalf("structured run OutputPath = %q, want raw generated text path %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath) } - if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) { + 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.ReportPath, result.MetadataPath, outputPath) + assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.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) @@ -252,7 +260,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if err != nil { t.Fatalf("read report output: %v", err) } - if !strings.Contains(string(report), "# Daily Report") { + if !strings.Contains(string(report), "# Friday's Weather") { t.Fatalf("report output missing markdown:\n%s", string(report)) } } @@ -754,8 +762,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), + Report: ReportThreeDay, }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) @@ -764,7 +771,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t result, err := GenerateReport(context.Background(), ReportRequest{ Config: cfg, Resolved: resolved, - Renderer: successfulRenderer("# Daily Report\n"), + Renderer: successfulRenderer("# 3-Day Outlook\n"), }) if err != nil { t.Fatalf("GenerateReport() error = %v", err) @@ -1125,7 +1132,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) { if req.PipelineID != "weatherreporter.daily" { t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID) } - if req.BundleID != "weatherreporter.home.daily_today" { + if req.BundleID != "weatherreporter.home.daily" { t.Fatalf("notification BundleID = %q, want default template", req.BundleID) } if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID { @@ -1208,10 +1215,9 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) { { name: "Run", renderer: &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"}, - runErr: errors.New("run failed"), - runBody: "# Daily Report\n", + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"}, + structuredRunErr: errors.New("run failed"), }, }, } @@ -1350,12 +1356,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) { } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, - runResult: &scriptorium.RunResult{ + structuredRunResult: &scriptorium.StructuredRunResult{ Stderr: "validation failed", ExitCode: 2, }, - runErr: errors.New("scriptorium run exited with code 2: validation failed"), - runBody: "# Daily Report\n", + structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"), } _, err = GenerateReport(context.Background(), ReportRequest{ @@ -1366,8 +1371,8 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) { if err == nil { t.Fatal("GenerateReport() error = nil, want run error") } - if renderer.renderCalls != 1 || renderer.runCalls != 1 { - t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls) + 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) } store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { @@ -1380,8 +1385,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) { if _, err := os.Stat(paths.Metadata); err != nil { t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err) } - if _, err := os.Stat(paths.RenderedReport); err != nil { - t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err) + if _, err := os.Stat(paths.GeneratedTextRaw); err != nil { + t.Fatalf("expected raw generated text from failed run %q: %v", paths.GeneratedTextRaw, err) + } + if _, err := os.Stat(paths.RenderedReport); !os.IsNotExist(err) { + t.Fatalf("rendered report exists after failed generated-text run: %v", err) } } @@ -2552,6 +2560,10 @@ func validTodayGeneratedTextJSON() string { return `{"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."}` } +func validDailyGeneratedTextJSON() string { + return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}` +} + func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { t.Helper() cfg.Workspace.Root = t.TempDir() @@ -2835,9 +2847,10 @@ func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metada func successfulRenderer(body string) *recordingRenderer { return &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - runResult: &scriptorium.RunResult{ExitCode: 0}, - runBody: body, + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + runBody: body, } } @@ -2913,6 +2926,9 @@ func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.Str if req.PromptID == "weather.tomorrow_generated_text" { body = validTomorrowGeneratedTextJSON() } + if req.PromptID == "weather.daily_generated_text" { + body = validDailyGeneratedTextJSON() + } if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { return nil, err } @@ -2942,8 +2958,12 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) ( func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { r.structuredRunCalls++ r.structuredRunRequest = req - if r.structuredRunBody != "" { - if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil { + body := r.structuredRunBody + if body == "" { + body = validGeneratedTextJSONForPrompt(req.PromptID) + } + if body != "" { + if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { return nil, err } } @@ -2952,3 +2972,18 @@ func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.Str } return r.structuredRunResult, r.structuredRunErr } + +func validGeneratedTextJSONForPrompt(promptID string) string { + switch promptID { + case "weather.daily_generated_text": + return validDailyGeneratedTextJSON() + case "weather.today_generated_text": + return validTodayGeneratedTextJSON() + case "weather.tomorrow_generated_text": + return validTomorrowGeneratedTextJSON() + case "weather.hourly_generated_text": + return validHourlyGeneratedTextJSON() + default: + return "" + } +} diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 8ce030f..81989f7 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -202,7 +202,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[MetadataModule](t, output) - if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { + if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" { t.Fatalf("metadata = %#v, want report identity", value) } if value.Location == nil || value.Location.Name != "Brentwood" { @@ -395,7 +395,7 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) { func testModuleContext() ModuleContext { generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") - definition := report.DefaultRegistry().MustLookup(report.DailyToday) + definition := report.DefaultRegistry().MustLookup(report.Daily) resolved := report.Resolved{ Definition: definition, GeneratedAt: generatedAt, diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index cbff00b..d244f80 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -16,7 +16,7 @@ import ( func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { registry := MustDefaultModuleRegistry() - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) if err != nil { @@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { registry := MustDefaultModuleRegistry() - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) ctx.Derived.DailySummaries[0].NarrativePeriods = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) @@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { registry := MustDefaultModuleRegistry() - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { @@ -154,7 +154,7 @@ func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { registry := MustDefaultModuleRegistry() - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) if err != nil { @@ -380,7 +380,7 @@ func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) { func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() - for _, id := range []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} { + for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} { t.Run(string(id), func(t *testing.T) { ctx := derivedModuleContext(id) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) @@ -453,7 +453,7 @@ func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) { func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() - for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { + for _, id := range []report.ID{report.Tomorrow, report.Daily} { t.Run(string(id), func(t *testing.T) { ctx := derivedModuleContext(id) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) @@ -484,7 +484,7 @@ func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) { func TestDerivedModulesHandleMissingData(t *testing.T) { registry := MustDefaultModuleRegistry() - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) ctx.Derived.DailySummaries = nil ctx.Derived.DaypartSummaries = nil @@ -576,7 +576,7 @@ func derivedModuleContext(id report.ID) ModuleContext { } func todayModuleContext() ModuleContext { - ctx := derivedModuleContext(report.DailyToday) + ctx := derivedModuleContext(report.Daily) ctx.Resolved.Definition = report.Definition{ ID: report.Today, Name: "Today Report", @@ -588,7 +588,7 @@ func todayModuleContext() ModuleContext { func dailyModuleContext() ModuleContext { ctx := derivedModuleContext(report.Tomorrow) ctx.Resolved.Definition = report.Definition{ - ID: report.ID("daily"), + ID: report.Daily, Name: "Daily Report", PromptID: "weather.daily_generated_text", } diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 3641690..c7e7a36 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -249,9 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error { } func defaultModuleDefinitions() []ModuleDefinition { - dailyReportID := report.ID("daily") - allReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} - daypartReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend} + allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} + daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend} return []ModuleDefinition{ { ID: module.Metadata, @@ -277,7 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition { DefaultOptions: module.NarrativeForecastOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, - SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow}, + SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, MissingData: module.MissingDataOmit, Builder: buildNarrativeForecastModule, }, @@ -287,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition { DefaultOptions: module.HourlyForecastOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, - SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly}, + SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}, MissingData: module.MissingDataOmit, Builder: buildHourlyForecastModule, }, @@ -296,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition { StanzaName: "derived_daily_summary", DefaultOptions: module.DerivedDailySummaryOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, - SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow}, + SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, MissingData: module.MissingDataError, Builder: buildDerivedDailySummaryModule, }, @@ -398,7 +397,7 @@ func defaultModuleDefinitions() []ModuleDefinition { StanzaName: "daily_planning", DefaultOptions: module.DailyPlanningOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, - SupportedReports: []report.ID{dailyReportID}, + SupportedReports: []report.ID{report.Daily}, MissingData: module.MissingDataEmpty, Builder: buildDailyPlanningModule, }, diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 526107d..941b683 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -77,7 +77,7 @@ func TestHourlyDefaultModuleOptions(t *testing.T) { func TestModuleRegistryRejectsUnknownModule(t *testing.T) { registry := MustDefaultModuleRegistry() - err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) + err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}}) if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) { t.Fatalf("error = %v, want unknown module", err) } @@ -85,7 +85,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) { func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) { registry := MustDefaultModuleRegistry() - err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + err := registry.ValidateComposition(report.Daily, []module.ConfigItem{ {ID: module.Metadata}, {ID: module.Metadata}, }) @@ -106,8 +106,8 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) { func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { registry := MustDefaultModuleRegistry() - err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}}) - if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) { + err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}}) + if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) { t.Fatalf("error = %v, want incompatible report", err) } } @@ -117,7 +117,7 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) { if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil { t.Fatalf("ValidateComposition(today) error = %v", err) } - for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { + for _, id := range []report.ID{report.Tomorrow, report.Daily} { t.Run(string(id), func(t *testing.T) { err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}}) if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) { @@ -129,10 +129,10 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) { func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) { registry := MustDefaultModuleRegistry() - if err := registry.ValidateComposition(report.ID("daily"), []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil { + if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil { t.Fatalf("ValidateComposition(daily) error = %v", err) } - for _, id := range []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} { + for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} { t.Run(string(id), func(t *testing.T) { err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}}) if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) { @@ -204,7 +204,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) { func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { registry := MustDefaultModuleRegistry() - err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + err := registry.ValidateComposition(report.Daily, []module.ConfigItem{ {ID: module.Metadata, Options: module.CurrentConditionsOptions{}}, }) if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) { @@ -214,7 +214,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { registry := MustDefaultModuleRegistry() - err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + err := registry.ValidateComposition(report.Daily, []module.ConfigItem{ {ID: module.Metadata, Options: module.MetadataOptions{}}, {ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}}, }) diff --git a/internal/briefing/package.go b/internal/briefing/package.go index ce4892f..3ce5bfb 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus { func variantForReport(id report.ID) string { switch id { - case report.DailyToday, report.Today: + case report.Daily, report.Today: return "today" case report.Tomorrow: return "tomorrow" diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ac27c1d..106cdef 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -251,7 +251,7 @@ func TestBatchOutputIncludesNotificationDetails(t *testing.T) { Failed: 1, Reports: []app.BatchReportResult{ { - ReportID: "daily_today", + ReportID: "daily", Status: "succeeded", OutputPath: "/tmp/daily.md", NotificationStatus: "accepted", @@ -299,11 +299,11 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) { Failed: 1, Reports: []app.BatchReportResult{ { - ReportID: "daily_today", + ReportID: "daily", Status: "failed", - Error: "notify report daily_today: upload failed: [redacted]", + Error: "notify report daily: upload failed: [redacted]", NotificationStatus: "failed", - NotificationError: "notify report daily_today: upload failed: [redacted]", + NotificationError: "notify report daily: upload failed: [redacted]", }, }, } @@ -526,7 +526,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { if err != nil { t.Fatalf("read report: %v", err) } - if !strings.Contains(string(report), "# Daily Report") { + if !strings.Contains(string(report), "# Friday's Weather") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) @@ -540,7 +540,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { if err != nil { t.Fatalf("read managed data package: %v", err) } - if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") { + if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily") { t.Fatalf("data package output missing expected content:\n%s", string(data)) } if !strings.Contains(string(data), "location:") || @@ -1299,6 +1299,21 @@ JSON ], "precipitation_timing": "The best rain chance is during the morning." } +JSON + printf 'wrote generated text\n' >&2 + exit 0 + fi + if [ "$prompt" = "weather.daily_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Showers are possible during the selected day.", + "forecast_discussion": [ + "A front will keep rain chances in the forecast.", + "Temperatures stay seasonable by afternoon." + ], + "precipitation_timing": "Rain is most likely during the afternoon.", + "confidence": "Medium" +} JSON printf 'wrote generated text\n' >&2 exit 0 @@ -1345,6 +1360,21 @@ 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.daily_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Showers are possible during the selected day.", + "forecast_discussion": [ + "A front will keep rain chances in the forecast.", + "Temperatures stay seasonable by afternoon." + ], + "precipitation_timing": "Rain is most likely during the afternoon.", + "confidence": "Medium" +} JSON printf 'wrote generated text\n' >&2 exit 0 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 19ee92e..298b48d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,7 +163,7 @@ reports: if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } - items := overrides[report.DailyToday] + items := overrides[report.Daily] if len(items) != 6 { t.Fatalf("daily override length = %d, want 6", len(items)) } @@ -220,8 +220,8 @@ reports: t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id) } } - if _, ok := overrides[report.DailyToday]; ok { - t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.DailyToday]) + if _, ok := overrides[report.Daily]; ok { + t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily]) } } @@ -320,12 +320,12 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) { wantErr string }{ { - name: "DuplicateAlias", + name: "RetiredDailyReportID", reports: map[string]ReportConfig{ "daily": {}, "daily_today": {}, }, - wantErr: "duplicates report override", + wantErr: "reports.daily_today", }, { name: "TodayAndDailyAreDistinct", @@ -425,7 +425,7 @@ reports: deterministic_modules: - tomorrow_planning `, - wantErr: `not compatible with report "daily_today"`, + wantErr: `not compatible with report "daily"`, }, { name: "RemovedPlaceholderModule", @@ -461,7 +461,7 @@ reports: wantErr: "options are invalid", }, { - name: "DuplicateReportAlias", + name: "RetiredDailyReportID", yaml: ` reports: daily: @@ -471,7 +471,7 @@ reports: deterministic_modules: - current_conditions `, - wantErr: "duplicates report override", + wantErr: "reports.daily_today", }, { name: "HourlyIncompatibleDailyModule", diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 5684537..99161c0 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { switch req.Resolved.Definition.ID { case report.Hourly: - case report.DailyToday, report.Today, report.Tomorrow: + case report.Daily, report.Today, report.Tomorrow: summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) if err != nil { return DerivedFacts{}, err diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index e7f84ec..908b2e2 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -56,7 +56,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { location := testLocation() - resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) + resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location) derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), @@ -253,7 +253,7 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) { }{ { name: "daily today", - resolved: resolveForTest(t, report.DailyToday, now, location), + resolved: resolveForTest(t, report.Daily, now, location), wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}, wantDiscussion: []string{"day1 early", "day1 late"}, }, @@ -322,7 +322,7 @@ func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) { func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) { location := testLocation() - resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) + resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location) bundle := testBundle(location) bundle.SPCConvectiveOutlooks = nil @@ -465,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition { func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved { t.Helper() - resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location}) + req := report.ResolveRequest{Now: now, Location: location} + if id == report.Daily { + req.Date = now + } + resolved, err := report.Resolve(id, req) if err != nil { t.Fatalf("resolve %s: %v", id, err) } diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index 6de7e5d..8fe8865 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -121,7 +121,7 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) { func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) { definition := report.Definition{ - ID: report.ID("daily"), + ID: report.Daily, GenerationMode: report.GenerationModeGeneratedTextTemplate, GeneratedTextSchemaID: "daily", TemplateID: "daily", @@ -206,7 +206,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { } dailyHandler, err := LookupDefinition(report.Definition{ - ID: report.ID("daily"), + ID: report.Daily, GenerationMode: report.GenerationModeGeneratedTextTemplate, GeneratedTextSchemaID: "daily", TemplateID: "daily", @@ -281,7 +281,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { } dailyHandler, err := LookupDefinition(report.Definition{ - ID: report.ID("daily"), + ID: report.Daily, GenerationMode: report.GenerationModeGeneratedTextTemplate, GeneratedTextSchemaID: "daily", TemplateID: "daily", @@ -303,7 +303,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) { handler, err := LookupDefinition(report.Definition{ - ID: report.ID("daily"), + ID: report.Daily, GenerationMode: report.GenerationModeGeneratedTextTemplate, GeneratedTextSchemaID: "daily", TemplateID: "daily", diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index c8cb676..63a0462 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -290,7 +290,7 @@ func TestBuildDailyRenderContext(t *testing.T) { if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" { t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone) } - if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.ID("daily") { + if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Daily { t.Fatalf("Modules.Metadata = %#v, want daily metadata", ctx.Modules.Metadata) } if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { @@ -544,7 +544,7 @@ func testDailyMetadata() briefing.Metadata { generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC) return briefing.Metadata{ RunID: "run-daily", - ReportID: report.ID("daily"), + ReportID: report.Daily, PromptID: "weather.daily_generated_text", GeneratedAt: generatedAt, Units: "imperial", @@ -763,7 +763,7 @@ func testDailySnapshot(t *testing.T) module.Snapshot { StanzaName: string(module.Metadata), Value: briefing.MetadataModule{ RunID: "run-daily", - ReportID: report.ID("daily"), + ReportID: report.Daily, PromptID: "weather.daily_generated_text", GeneratedAt: testDailyMetadata().GeneratedAt, Units: "imperial", diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index 3514ea8..83e08ad 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -40,6 +40,7 @@ var briefingStanzaCategories = map[string]string{ string(module.DerivedDaypartSummaries): categoryDerivedSummaries, string(module.PrecipTiming): categoryDerivedSummaries, string(module.OutdoorWindows): categoryDerivedSummaries, + string(module.DailyPlanning): categoryDerivedSummaries, string(module.TodayPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.NarrativeForecast): categoryNarrativeProducts, diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index 135125e..b08c15d 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) { if pkg.SchemaVersion != SchemaVersion { t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) } - if pkg.RunID != "20260529T100000Z_daily_today" { + if pkg.RunID != "20260529T100000Z_daily" { t.Fatalf("RunID = %q, want metadata run id", pkg.RunID) } - if pkg.Report.PromptID != "weather.daily_report" { - t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) + if pkg.Report.PromptID != "weather.daily_generated_text" { + t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID) } if pkg.Report.CurrentLocalDate != "2026-05-29" { t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) @@ -252,10 +252,10 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) { func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { data := []byte(` schema_version: weatherreporter.data_package.v2 -run_id: 20260529T100000Z_daily_today +run_id: 20260529T100000Z_daily report: - id: daily_today - prompt_id: weather.daily_report + id: daily + prompt_id: weather.daily_generated_text generated_at: 2026-05-29T10:00:00Z timezone: America/Chicago current_local_date: "2026-05-29" @@ -264,7 +264,7 @@ report: end: 2026-05-30T05:00:00Z briefing: metadata: - run_id: 20260529T100000Z_daily_today + run_id: 20260529T100000Z_daily raw_data: alert_digest: checked: true @@ -283,10 +283,10 @@ func validBuildRequest(t *testing.T) BuildRequest { generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) return BuildRequest{ Metadata: Metadata{ - RunID: "20260529T100000Z_daily_today", - ReportID: report.DailyToday, + RunID: "20260529T100000Z_daily", + ReportID: report.Daily, Variant: "today", - PromptID: "weather.daily_report", + PromptID: "weather.daily_generated_text", GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{ @@ -295,7 +295,7 @@ func validBuildRequest(t *testing.T) BuildRequest { }, }, Modules: snapshotWithOutputs(t, - module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}}, + module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}}, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}}, diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 3c8c773..39c95e5 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -1,24 +1,27 @@ package report import ( + "fmt" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) -func dailyTodayDefinition() Definition { +func dailyDefinition() Definition { return Definition{ - ID: DailyToday, - Name: "Daily Report", - PromptID: "weather.daily_report", - GenerationMode: GenerationModeScriptoriumMarkdown, - ComparisonStrategy: CompareSameValidDate, - ArtifactGroup: "daily", - BatchOutputName: "daily.md", - Generated: true, - CompatiblePriorIDs: []ID{DailyToday}, - Modules: dailyTodayModules(), - Morning: true, - resolve: resolveDailyToday, + ID: Daily, + Name: "Daily Report", + PromptID: "weather.daily_generated_text", + GenerationMode: GenerationModeGeneratedTextTemplate, + TemplateID: "daily", + GeneratedTextSchemaID: "daily", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "daily", + BatchOutputName: "daily.md", + Generated: true, + CompatiblePriorIDs: []ID{Daily}, + Modules: dailyModules(), + resolve: resolveDaily, } } @@ -41,7 +44,7 @@ func tomorrowDefinition() Definition { } } -func dailyTodayModules() []module.ConfigItem { +func dailyModules() []module.ConfigItem { return moduleItems( module.Metadata, module.CurrentConditions, @@ -55,6 +58,7 @@ func dailyTodayModules() []module.ConfigItem { module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, + module.DailyPlanning, module.HourlyForecast, ) } @@ -78,11 +82,11 @@ func tomorrowModules() []module.ConfigItem { ) } -func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) { - if !req.Date.IsZero() { - return timeutil.CivilDay(req.Date, req.Location), nil +func resolveDaily(req ResolveRequest) (timeutil.Period, error) { + if req.Date.IsZero() { + return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date") } - return timeutil.CivilDay(req.Now, req.Location), nil + return timeutil.CivilDay(req.Date, req.Location), nil } func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) { diff --git a/internal/report/definition.go b/internal/report/definition.go index 52172e9..a912d74 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -12,13 +12,13 @@ import ( type ID string const ( - DailyToday ID = "daily_today" - Today ID = "today" - Tomorrow ID = "tomorrow" - Hourly ID = "hourly" - ThreeDay ID = "three_day" - Weekend ID = "weekend" - Storm ID = "storm" + Daily ID = "daily" + Today ID = "today" + Tomorrow ID = "tomorrow" + Hourly ID = "hourly" + ThreeDay ID = "three_day" + Weekend ID = "weekend" + Storm ID = "storm" ) type ComparisonStrategy string diff --git a/internal/report/names.go b/internal/report/names.go index dc66b28..3f4eead 100644 --- a/internal/report/names.go +++ b/internal/report/names.go @@ -21,7 +21,7 @@ const ( func IDForCommandName(name string) (ID, error) { switch name { case CommandNameDaily: - return DailyToday, nil + return Daily, nil case CommandNameToday: return Today, nil case CommandNameTomorrow: @@ -54,8 +54,8 @@ func CommandNames() []string { func IDForConfigKey(key string) (ID, error) { normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_") switch normalized { - case "daily", "daily_today": - return DailyToday, nil + case "daily": + return Daily, nil case "today": return Today, nil case "tomorrow": diff --git a/internal/report/period_test.go b/internal/report/period_test.go index e7b79e9..4947d98 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T17:45:00-05:00") - resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location}) - if err != nil { - t.Fatalf("Resolve() error = %v", err) + resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location}) + if err == nil { + t.Fatal("Resolve() error = nil, want explicit date requirement") } - assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00") + if !strings.Contains(err.Error(), "requires an explicit date") { + t.Fatalf("Resolve() error = %v, want explicit date requirement", err) + } + _ = resolved } func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { @@ -26,11 +29,23 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { now := mustParse("2026-05-29T17:45:00-05:00") date := mustParse("2026-05-31T12:00:00-05:00") - resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date}) + resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") + if resolved.Definition.PromptID != "weather.daily_generated_text" { + t.Fatalf("PromptID = %q, want weather.daily_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 != "daily" { + t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID) + } + if resolved.Definition.GeneratedTextSchemaID != "daily" { + t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID) + } } func TestTodayValidPeriod(t *testing.T) { @@ -256,7 +271,7 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) { } } -func TestBatchesDoNotIncludeHourly(t *testing.T) { +func TestBatchesDoNotIncludeManualReports(t *testing.T) { location := mustLoadLocation(t) req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location} @@ -269,8 +284,8 @@ func TestBatchesDoNotIncludeHourly(t *testing.T) { t.Fatalf("BatchReports(evening) error = %v", err) } for _, resolved := range append(morning, evening...) { - if resolved.Definition.ID == Hourly { - t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID) + if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily { + t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID) } } } @@ -280,7 +295,7 @@ func TestIDForCommandName(t *testing.T) { name string want ID }{ - {name: "daily", want: DailyToday}, + {name: "daily", want: Daily}, {name: "today", want: Today}, {name: "tomorrow", want: Tomorrow}, {name: "hourly", want: Hourly}, @@ -323,8 +338,7 @@ func TestIDForConfigKey(t *testing.T) { key string want ID }{ - {key: "daily", want: DailyToday}, - {key: "daily_today", want: DailyToday}, + {key: "daily", want: Daily}, {key: "today", want: Today}, {key: "tomorrow", want: Tomorrow}, {key: "hourly", want: Hourly}, @@ -351,6 +365,9 @@ func TestIDForConfigKey(t *testing.T) { if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) { t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err) } + if _, err := IDForConfigKey("daily_today"); err == nil || !strings.Contains(err.Error(), `report config key "daily_today" is not a known report`) { + t.Fatalf("IDForConfigKey(daily_today) error = %v, want unknown key", err) + } dailyID, err := IDForConfigKey("daily") if err != nil { t.Fatalf("IDForConfigKey(daily) error = %v", err) @@ -418,7 +435,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) { ids := resolvedDefinitionIDs(DefaultRegistry().All()) - want := []string{"daily_today", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"} + want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"} if strings.Join(ids, ",") != strings.Join(want, ",") { t.Fatalf("All() ids = %#v, want %#v", ids, want) } @@ -443,7 +460,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) { if !definition.Generated { continue } - if definition.ID == Hourly || definition.ID == Today || definition.ID == Tomorrow { + if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow { wantTemplate := string(definition.ID) if definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate) @@ -478,11 +495,11 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { comparisonStrategy ComparisonStrategy }{ { - id: DailyToday, + id: Daily, artifactGroup: "daily", batchOutputName: "daily.md", generated: true, - compatiblePriorIDs: []ID{DailyToday}, + compatiblePriorIDs: []ID{Daily}, comparisonStrategy: CompareSameValidDate, }, { @@ -572,7 +589,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { want []module.ID }{ { - id: DailyToday, + id: Daily, want: []module.ID{ module.Metadata, module.CurrentConditions, @@ -586,6 +603,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, + module.DailyPlanning, module.HourlyForecast, }, }, @@ -703,7 +721,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { base := DefaultRegistry() overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{ - DailyToday: { + Daily: { {ID: module.Metadata}, {ID: module.AlertDigest}, }, @@ -712,7 +730,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { t.Fatalf("WithModuleOverrides() error = %v", err) } - definition, err := overridden.Lookup(DailyToday) + definition, err := overridden.Lookup(Daily) if err != nil { t.Fatalf("Lookup(overridden) error = %v", err) } @@ -720,7 +738,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs()) } - defaultDefinition, err := base.Lookup(DailyToday) + defaultDefinition, err := base.Lookup(Daily) if err != nil { t.Fatalf("Lookup(default) error = %v", err) } @@ -743,18 +761,22 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) { func TestResolvedMetadata(t *testing.T) { location := mustLoadLocation(t) - resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) + resolved, err := Resolve(Daily, ResolveRequest{ + Now: mustParse("2026-05-29T05:00:00-05:00"), + Location: location, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }) if err != nil { t.Fatalf("Resolve() error = %v", err) } metadata := resolved.Metadata() - if metadata.ReportID != DailyToday { - t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID) + if metadata.ReportID != Daily { + t.Fatalf("ReportID = %q, want daily", metadata.ReportID) } - if metadata.PromptID != "weather.daily_report" { - t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID) + if metadata.PromptID != "weather.daily_generated_text" { + t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID) } - if !strings.Contains(metadata.RunID, "daily_today") { + if !strings.Contains(metadata.RunID, "daily") { t.Fatalf("RunID = %q, want report id", metadata.RunID) } } diff --git a/internal/report/registry.go b/internal/report/registry.go index 8897bca..6258569 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -12,7 +12,7 @@ type Registry struct { func DefaultRegistry() Registry { definitions := []Definition{ - dailyTodayDefinition(), + dailyDefinition(), todayDefinition(), tomorrowDefinition(), hourlyDefinition(), @@ -75,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition { } func (r Registry) All() []Definition { - ids := []ID{DailyToday, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm} + ids := []ID{Daily, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm} out := make([]Definition, 0, len(ids)) for _, id := range ids { if definition, ok := r.definitions[id]; ok { diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 1a67eed..ff54c57 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { } for _, want := range []string{ - filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"), - filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"), - filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"), - filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"), - filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"), - filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"), + filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.modules.json"), + filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.metadata.json"), + filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.data_package.yaml"), + filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.render.json"), + filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.distributor.json"), + filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.md"), } { if !strings.Contains(pathsString(paths), want) { t.Fatalf("paths = %#v, want component %q", paths, want) @@ -49,6 +49,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { validDate string runID string }{ + { + name: "daily", + resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"), + group: "daily", + validDate: "2026-05-29", + runID: "20260529T100000.000000000Z_daily", + }, { name: "hourly", resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"), @@ -243,9 +250,12 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) } - for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} { + if decoded.GeneratedTextSchemaID != "daily" { + t.Fatalf("GeneratedTextSchemaID = %q, want daily", decoded.GeneratedTextSchemaID) + } + for _, unexpected := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} { if strings.Contains(string(data), unexpected) { - t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data)) + t.Fatalf("metadata JSON includes unsaved generated-text path field %q:\n%s", unexpected, string(data)) } } } @@ -547,9 +557,10 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved { if err != nil { t.Fatalf("parse time: %v", err) } - resolved, err := report.DefaultRegistry().Resolve(report.DailyToday, report.ResolveRequest{ + resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ Now: now, Location: location, + Date: now, }) if err != nil { t.Fatalf("Resolve() error = %v", err)