diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 3b51d64..a6f5c99 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -33,7 +33,7 @@ Outputs: `weather_story` - `module.Output` values for derived stanzas: `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, - `outdoor_windows`, and `tomorrow_planning` + `outdoor_windows`, `today_planning`, and `tomorrow_planning` Every registered composition entry has a builder. Unknown or unimplemented module IDs fail validation instead of being skipped. @@ -42,13 +42,17 @@ 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. +`today_planning` is a Today-specific deterministic planning stanza with +morning readiness, commute/school/workday concerns, outdoor planning, and +late-day change-watch fields. It is compatible with `report.Today` only. + 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`, or -`tomorrow_planning`. +`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, +`today_planning`, or `tomorrow_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 a1a1ece..753b425 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -42,6 +42,7 @@ The registry recognizes these IDs: - `spc_convective_discussion` - `weather_story` - `outdoor_windows` +- `today_planning` - `tomorrow_planning` Every registered module has a builder. Report composition entries that refer to diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 59f5482..005e729 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -337,6 +337,80 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing } } +func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := todayModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) + if err != nil { + t.Fatalf("BuildModule(today planning) error = %v", err) + } + if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" { + t.Fatalf("output = %#v, want today planning stanza", output) + } + planning := moduleValue[TodayPlanningModule](t, output) + if len(planning.MorningReadiness) == 0 || + len(planning.CommuteSchoolWorkdayConcerns) == 0 || + len(planning.OutdoorPlanning) == 0 || + len(planning.LateDayChangeWatch) == 0 { + t.Fatalf("today 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.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") || + !containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") { + t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning) + } + if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") { + t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("marshal today planning: %v", err) + } + jsonText := string(data) + for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("today planning json = %s, want field %s", jsonText, field) + } + } + if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") { + t.Fatalf("today planning json = %s, want snake_case fields", jsonText) + } +} + +func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) { + registry := MustDefaultModuleRegistry() + for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { + t.Run(string(id), func(t *testing.T) { + ctx := derivedModuleContext(id) + _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) + if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) { + t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err) + } + }) + } +} + +func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := todayModuleContext() + ctx.Derived.DailySummaries = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) + if err != nil { + t.Fatalf("BuildModule(today planning) error = %v", err) + } + planning := moduleValue[TodayPlanningModule](t, output) + if len(planning.MorningReadiness) != 0 || + len(planning.CommuteSchoolWorkdayConcerns) != 0 || + len(planning.OutdoorPlanning) != 0 || + len(planning.LateDayChangeWatch) != 0 { + t.Fatalf("today planning = %#v, want empty output without daily summary", planning) + } +} + func TestDerivedModulesHandleMissingData(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) @@ -430,6 +504,16 @@ func derivedModuleContext(id report.ID) ModuleContext { } } +func todayModuleContext() ModuleContext { + ctx := derivedModuleContext(report.DailyToday) + ctx.Resolved.Definition = report.Definition{ + ID: report.Today, + Name: "Today Report", + PromptID: "weather.today_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 7080be8..a32f054 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error { } func defaultModuleDefinitions() []ModuleDefinition { - allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} - daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend} + 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{ { ID: module.Metadata, @@ -276,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.Tomorrow}, + SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow}, MissingData: module.MissingDataOmit, Builder: buildNarrativeForecastModule, }, @@ -286,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.Tomorrow, report.Hourly}, + SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly}, MissingData: module.MissingDataOmit, Builder: buildHourlyForecastModule, }, @@ -295,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.Tomorrow}, + SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow}, MissingData: module.MissingDataError, Builder: buildDerivedDailySummaryModule, }, @@ -374,6 +374,15 @@ func defaultModuleDefinitions() []ModuleDefinition { MissingData: module.MissingDataEmpty, Builder: buildOutdoorWindowsModule, }, + { + ID: module.TodayPlanning, + StanzaName: "today_planning", + DefaultOptions: module.TodayPlanningOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + SupportedReports: []report.ID{report.Today}, + MissingData: module.MissingDataEmpty, + Builder: buildTodayPlanningModule, + }, { ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 5ad7931..560ae6e 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -112,6 +112,43 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { } } +func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) { + registry := MustDefaultModuleRegistry() + 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} { + 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`) { + 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{ + {ID: module.Metadata}, + {ID: module.CurrentConditions}, + {ID: module.NarrativeForecast}, + {ID: module.HourlyForecast}, + {ID: module.DerivedDailySummary}, + {ID: module.DerivedDaypartSummaries}, + {ID: module.PrecipTiming}, + {ID: module.AlertDigest}, + {ID: module.SPCConvectiveOutlooks}, + {ID: module.AreaForecastDiscussion}, + {ID: module.SPCConvectiveDiscussion}, + {ID: module.WeatherStory}, + {ID: module.OutdoorWindows}, + }) + if err != nil { + t.Fatalf("ValidateComposition(today eligible modules) error = %v", err) + } +} + func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) { registry := MustDefaultModuleRegistry() for _, id := range []module.ID{ @@ -119,6 +156,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) { module.DerivedDailySummary, module.DerivedDaypartSummaries, module.OutdoorWindows, + module.TodayPlanning, module.TomorrowPlanning, } { t.Run(string(id), func(t *testing.T) { diff --git a/internal/briefing/summary_helpers.go b/internal/briefing/summary_helpers.go index 65d88fd..afaf803 100644 --- a/internal/briefing/summary_helpers.go +++ b/internal/briefing/summary_helpers.go @@ -28,6 +28,13 @@ type TomorrowPlanning struct { OvernightChangeWatch []string } +type TodayPlanning struct { + MorningReadiness []string + CommuteSchoolWorkdayConcerns []string + OutdoorPlanning []string + LateDayChangeWatch []string +} + func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { var best *OutdoorWindow var worst *OutdoorWindow @@ -48,6 +55,49 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { return OutdoorWindows{Best: best, Worst: worst} } +func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning { + planning := &TodayPlanning{} + morning := daypartNamed(summary.Dayparts, "morning") + if morning != nil { + planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) + } + if len(planning.MorningReadiness) == 0 { + planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.") + } + + for _, daypart := range summary.Dayparts { + if daypart.Name == "overnight" || daypart.Name == "evening" { + continue + } + planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) + } + for _, alert := range summary.AlertOverlaps { + if alert.Event != "" { + planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".") + } + } + if len(planning.CommuteSchoolWorkdayConcerns) == 0 { + planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.") + } + + planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...) + if len(planning.OutdoorPlanning) == 0 { + planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.") + } + + for _, name := range []string{"afternoon", "evening"} { + daypart := daypartNamed(summary.Dayparts, name) + if daypart != nil { + planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...) + } + } + if len(planning.LateDayChangeWatch) == 0 { + planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.") + } + + return planning +} + func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { planning := &TomorrowPlanning{} morning := daypartNamed(summary.Dayparts, "morning") @@ -84,6 +134,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { return planning } +func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string { + windows := buildOutdoorWindows(dayparts) + var notes []string + if windows.Best != nil { + notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", "))) + } + if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) { + notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", "))) + } + return appendUnique(nil, notes...) +} + func readinessNotes(daypart forecast.DaypartSummary) []string { notes := []string{} if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { @@ -104,6 +166,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string { return appendUnique(nil, notes...) } +func lateDayWatchNotes(daypart forecast.DaypartSummary) []string { + notes := []string{} + prefix := titleWord(daypart.Name) + if prefix == "" { + prefix = "Late-day" + } + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { + notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value)) + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { + notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value)) + } + if daypart.Indicators.Snow || daypart.Indicators.Ice { + notes = append(notes, prefix+" wintry weather could affect late-day travel.") + } + if len(daypart.AlertOverlaps) > 0 { + notes = append(notes, prefix+" alert timing could affect late-day plans.") + } + return appendUnique(nil, notes...) +} + func concernNotes(daypart forecast.DaypartSummary) []string { notes := []string{} prefix := titleWord(daypart.Name) diff --git a/internal/briefing/today_planning_module.go b/internal/briefing/today_planning_module.go new file mode 100644 index 0000000..173b71b --- /dev/null +++ b/internal/briefing/today_planning_module.go @@ -0,0 +1,26 @@ +package briefing + +import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + +type TodayPlanningModule struct { + MorningReadiness []string `json:"morning_readiness,omitempty"` + CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` + OutdoorPlanning []string `json:"outdoor_planning,omitempty"` + LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"` +} + +func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil + } + planning := buildTodayPlanning(summary) + value := TodayPlanningModule{} + if planning != nil { + value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) + value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) + value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...) + value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...) + } + return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil +} diff --git a/internal/module/module.go b/internal/module/module.go index b4a64f6..bf55475 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -24,6 +24,7 @@ const ( SPCConvectiveOutlooks ID = "spc_convective_outlooks" SPCConvectiveDiscussion ID = "spc_convective_discussion" OutdoorWindows ID = "outdoor_windows" + TodayPlanning ID = "today_planning" TomorrowPlanning ID = "tomorrow_planning" ) @@ -148,4 +149,5 @@ type WeatherStoryOptions struct{} type SPCConvectiveOutlooksOptions struct{} type SPCConvectiveDiscussionOptions struct{} type OutdoorWindowsOptions struct{} +type TodayPlanningOptions struct{} type TomorrowPlanningOptions struct{} diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index c82dc7a..3514ea8 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.TodayPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.NarrativeForecast): categoryNarrativeProducts, string(module.AreaForecastDiscussion): categoryNarrativeProducts, diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index e43585b..135125e 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) { req.Modules = snapshotWithOutputs(t, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}}, + module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}}, ) pkg, err := Build(req) @@ -120,6 +121,9 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) { if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok { t.Fatal("Briefing.Values[derived_daypart_summaries] missing") } + if _, ok := pkg.Briefing.Values["today_planning"]; !ok { + t.Fatal("Briefing.Values[today_planning] missing") + } } func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) { diff --git a/internal/report/definition.go b/internal/report/definition.go index 51358a1..52172e9 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -13,6 +13,7 @@ type ID string const ( DailyToday ID = "daily_today" + Today ID = "today" Tomorrow ID = "tomorrow" Hourly ID = "hourly" ThreeDay ID = "three_day"