From 4eece7cc8a2cf8a82b97af606c7d28454352e546 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 16:17:22 +0000 Subject: [PATCH] Add daily planning module --- docs/internal/briefing.md | 10 ++- docs/internal/module.md | 24 +++++-- internal/briefing/daily_planning_module.go | 24 +++++++ internal/briefing/derived_modules_test.go | 81 ++++++++++++++++++++++ internal/briefing/modules.go | 10 +++ internal/briefing/modules_test.go | 16 +++++ internal/briefing/summary_helpers.go | 17 ++++- internal/module/module.go | 2 + internal/module/module_test.go | 8 +++ 9 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 internal/briefing/daily_planning_module.go diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index ac61019..5fd207a 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -33,7 +33,8 @@ Outputs: `weather_story` - `module.Output` values for derived stanzas: `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, - `outdoor_windows`, `today_planning`, and `tomorrow_planning` + `outdoor_windows`, `today_planning`, `tomorrow_planning`, and + `daily_planning` Every registered composition entry has a builder. Unknown or unimplemented module IDs fail validation instead of being skipped. @@ -50,13 +51,18 @@ package and embedded Markdown template. morning readiness, commute/school/workday concerns, outdoor planning, and 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. + Hourly Report supports source and valid-period modules that operate over its rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`, `precip_timing`, `alert_digest`, `spc_convective_outlooks`, `area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`. It does not support daily/daypart-only modules such as `derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, -`today_planning`, or `tomorrow_planning`. +`today_planning`, `tomorrow_planning`, or `daily_planning`. Prompt-facing module values use local, human-readable date and time labels where the LLM is expected to reason about report content. Canonical timestamps diff --git a/docs/internal/module.md b/docs/internal/module.md index dff9a0a..e53489e 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -44,6 +44,7 @@ The registry recognizes these IDs: - `outdoor_windows` - `today_planning` - `tomorrow_planning` +- `daily_planning` Every registered module has a builder. Report composition entries that refer to unknown or unimplemented module IDs fail validation instead of being skipped. @@ -92,6 +93,22 @@ The default Tomorrow Report module order is: The embedded Tomorrow template uses selected deterministic fields from these module outputs after GeneratedText validation. +## Daily Planning + +`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. + +The output uses this shape: + +- `morning_readiness` +- `commute_school_workday_concerns` +- `overnight_change_watch` + +The type is `briefing.DailyPlanningModule`; it is independent from +`briefing.TomorrowPlanningModule`. + ## Today Planning `today_planning` emits current-day planning facts for Today Report. Its output @@ -99,13 +116,12 @@ stanza is also named `today_planning`. The module is supported only by Today Report and depends on daily and daypart summaries for the current local civil day. -The output uses the same shape as Tomorrow planning: +The output uses this shape: - `morning_readiness` -- `commute` +- `commute_school_workday_concerns` - `outdoor_planning` -- `things_to_watch` -- `confidence` +- `late_day_change_watch` The type is `briefing.TodayPlanningModule`; it is independent from `briefing.TomorrowPlanningModule`. diff --git a/internal/briefing/daily_planning_module.go b/internal/briefing/daily_planning_module.go new file mode 100644 index 0000000..2eb3d0f --- /dev/null +++ b/internal/briefing/daily_planning_module.go @@ -0,0 +1,24 @@ +package briefing + +import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + +type DailyPlanningModule struct { + MorningReadiness []string `json:"morning_readiness,omitempty"` + CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` + OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"` +} + +func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil + } + planning := buildMorningCommuteOvernightPlanning(summary) + value := DailyPlanningModule{} + if planning != nil { + value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) + value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) + value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...) + } + return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil +} diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 005e729..cbff00b 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -337,6 +337,77 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing } } +func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := dailyModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) + if err != nil { + t.Fatalf("BuildModule(daily planning) error = %v", err) + } + if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" { + t.Fatalf("output = %#v, want daily planning stanza", output) + } + planning := moduleValue[DailyPlanningModule](t, output) + if len(planning.MorningReadiness) == 0 || + len(planning.CommuteSchoolWorkdayConcerns) == 0 || + len(planning.OvernightChangeWatch) == 0 { + t.Fatalf("daily planning = %#v, want populated planning fields", planning) + } + if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") { + t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness) + } + if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") { + t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns) + } + if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") { + t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("marshal daily planning: %v", err) + } + jsonText := string(data) + for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("daily planning json = %s, want field %s", jsonText, field) + } + } + if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") { + t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText) + } +} + +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} { + t.Run(string(id), func(t *testing.T) { + ctx := derivedModuleContext(id) + _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) + if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) { + t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err) + } + }) + } +} + +func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := dailyModuleContext() + ctx.Derived.DailySummaries = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) + if err != nil { + t.Fatalf("BuildModule(daily planning) error = %v", err) + } + planning := moduleValue[DailyPlanningModule](t, output) + if len(planning.MorningReadiness) != 0 || + len(planning.CommuteSchoolWorkdayConcerns) != 0 || + len(planning.OvernightChangeWatch) != 0 { + t.Fatalf("daily planning = %#v, want empty output without daily summary", planning) + } +} + func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := todayModuleContext() @@ -514,6 +585,16 @@ func todayModuleContext() ModuleContext { return ctx } +func dailyModuleContext() ModuleContext { + ctx := derivedModuleContext(report.Tomorrow) + ctx.Resolved.Definition = report.Definition{ + ID: report.ID("daily"), + Name: "Daily Report", + PromptID: "weather.daily_generated_text", + } + return ctx +} + func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { hour := derivedHour(start, text, precip, temperature, apparent, gust) return forecast.SummarizeDaypart(name, timeutil.Period{ diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index a32f054..3641690 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -249,6 +249,7 @@ 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} return []ModuleDefinition{ @@ -392,5 +393,14 @@ func defaultModuleDefinitions() []ModuleDefinition { MissingData: module.MissingDataEmpty, Builder: buildTomorrowPlanningModule, }, + { + ID: module.DailyPlanning, + StanzaName: "daily_planning", + DefaultOptions: module.DailyPlanningOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + SupportedReports: []report.ID{dailyReportID}, + MissingData: module.MissingDataEmpty, + Builder: buildDailyPlanningModule, + }, } } diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 560ae6e..526107d 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -127,6 +127,21 @@ 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 { + 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} { + 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`) { + t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err) + } + }) + } +} + func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) { registry := MustDefaultModuleRegistry() err := registry.ValidateComposition(report.Today, []module.ConfigItem{ @@ -158,6 +173,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) { module.OutdoorWindows, module.TodayPlanning, module.TomorrowPlanning, + module.DailyPlanning, } { t.Run(string(id), func(t *testing.T) { err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}}) diff --git a/internal/briefing/summary_helpers.go b/internal/briefing/summary_helpers.go index afaf803..49af3d9 100644 --- a/internal/briefing/summary_helpers.go +++ b/internal/briefing/summary_helpers.go @@ -28,6 +28,12 @@ type TomorrowPlanning struct { OvernightChangeWatch []string } +type morningCommuteOvernightPlanning struct { + MorningReadiness []string + CommuteSchoolWorkdayConcerns []string + OvernightChangeWatch []string +} + type TodayPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string @@ -99,7 +105,16 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning { } func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { - planning := &TomorrowPlanning{} + base := buildMorningCommuteOvernightPlanning(summary) + return &TomorrowPlanning{ + MorningReadiness: append([]string(nil), base.MorningReadiness...), + CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...), + OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...), + } +} + +func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning { + planning := &morningCommuteOvernightPlanning{} morning := daypartNamed(summary.Dayparts, "morning") if morning != nil { planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) diff --git a/internal/module/module.go b/internal/module/module.go index bf55475..e14778a 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -26,6 +26,7 @@ const ( OutdoorWindows ID = "outdoor_windows" TodayPlanning ID = "today_planning" TomorrowPlanning ID = "tomorrow_planning" + DailyPlanning ID = "daily_planning" ) type ConfigItem struct { @@ -151,3 +152,4 @@ type SPCConvectiveDiscussionOptions struct{} type OutdoorWindowsOptions struct{} type TodayPlanningOptions struct{} type TomorrowPlanningOptions struct{} +type DailyPlanningOptions struct{} diff --git a/internal/module/module_test.go b/internal/module/module_test.go index b267065..de83208 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -100,3 +100,11 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) { _ = SPCConvectiveOutlooksOptions{} _ = SPCConvectiveDiscussionOptions{} } + +func TestDailyPlanningModuleContractIsStable(t *testing.T) { + if DailyPlanning != ID("daily_planning") { + t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning) + } + + _ = DailyPlanningOptions{} +}