Add daily planning module
This commit is contained in:
@@ -33,7 +33,8 @@ Outputs:
|
|||||||
`weather_story`
|
`weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`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
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
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
|
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||||
late-day change-watch fields. It is compatible with `report.Today` only.
|
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
|
Hourly Report supports source and valid-period modules that operate over its
|
||||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||||
It does not support daily/daypart-only modules such as
|
It does not support daily/daypart-only modules such as
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
`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
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ The registry recognizes these IDs:
|
|||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `today_planning`
|
- `today_planning`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
|
- `daily_planning`
|
||||||
|
|
||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
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
|
The embedded Tomorrow template uses selected deterministic fields from these
|
||||||
module outputs after GeneratedText validation.
|
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
|
||||||
|
|
||||||
`today_planning` emits current-day planning facts for Today Report. Its output
|
`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
|
Report and depends on daily and daypart summaries for the current local civil
|
||||||
day.
|
day.
|
||||||
|
|
||||||
The output uses the same shape as Tomorrow planning:
|
The output uses this shape:
|
||||||
|
|
||||||
- `morning_readiness`
|
- `morning_readiness`
|
||||||
- `commute`
|
- `commute_school_workday_concerns`
|
||||||
- `outdoor_planning`
|
- `outdoor_planning`
|
||||||
- `things_to_watch`
|
- `late_day_change_watch`
|
||||||
- `confidence`
|
|
||||||
|
|
||||||
The type is `briefing.TodayPlanningModule`; it is independent from
|
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||||
`briefing.TomorrowPlanningModule`.
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|||||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := todayModuleContext()
|
ctx := todayModuleContext()
|
||||||
@@ -514,6 +585,16 @@ func todayModuleContext() ModuleContext {
|
|||||||
return ctx
|
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 {
|
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)
|
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
|
dailyReportID := report.ID("daily")
|
||||||
allReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
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}
|
daypartReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
@@ -392,5 +393,14 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||||
@@ -158,6 +173,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
|||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TodayPlanning,
|
module.TodayPlanning,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
module.DailyPlanning,
|
||||||
} {
|
} {
|
||||||
t.Run(string(id), func(t *testing.T) {
|
t.Run(string(id), func(t *testing.T) {
|
||||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ type TomorrowPlanning struct {
|
|||||||
OvernightChangeWatch []string
|
OvernightChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type morningCommuteOvernightPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OvernightChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
type TodayPlanning struct {
|
type TodayPlanning struct {
|
||||||
MorningReadiness []string
|
MorningReadiness []string
|
||||||
CommuteSchoolWorkdayConcerns []string
|
CommuteSchoolWorkdayConcerns []string
|
||||||
@@ -99,7 +105,16 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
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")
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
if morning != nil {
|
if morning != nil {
|
||||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TodayPlanning ID = "today_planning"
|
TodayPlanning ID = "today_planning"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
|
DailyPlanning ID = "daily_planning"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigItem struct {
|
type ConfigItem struct {
|
||||||
@@ -151,3 +152,4 @@ type SPCConvectiveDiscussionOptions struct{}
|
|||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TodayPlanningOptions struct{}
|
type TodayPlanningOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
type DailyPlanningOptions struct{}
|
||||||
|
|||||||
@@ -100,3 +100,11 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
|
|||||||
_ = SPCConvectiveOutlooksOptions{}
|
_ = SPCConvectiveOutlooksOptions{}
|
||||||
_ = SPCConvectiveDiscussionOptions{}
|
_ = SPCConvectiveDiscussionOptions{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
|
||||||
|
if DailyPlanning != ID("daily_planning") {
|
||||||
|
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DailyPlanningOptions{}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user