From 9501dad1dc14d037090eb187593bb24ce230161c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:35:57 +0000 Subject: [PATCH] Implement source-oriented briefing modules --- docs/internal/briefing.md | 7 + internal/briefing/base_modules.go | 236 +++++++++++++++++++++ internal/briefing/base_modules_test.go | 276 +++++++++++++++++++++++++ internal/briefing/modules.go | 52 +++++ 4 files changed, 571 insertions(+) create mode 100644 internal/briefing/base_modules.go create mode 100644 internal/briefing/base_modules_test.go diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 69dfe16..f1071d1 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -23,6 +23,8 @@ Outputs: object for Daily, 3-Day, Weekend, or Storm Report - module registry definitions for known module IDs, stanza names, option shapes, fact requirements, report compatibility, and missing-data behavior +- source-oriented module outputs for `metadata`, `current_conditions`, + `alert_digest`, `area_forecast_discussion`, and `weather_story` - optional `currentConditions` prompt context from normalized `/conditions/current` data when available - optional structured `weatherStory` context on report-specific briefing @@ -73,6 +75,10 @@ None. Builders either return a complete briefing package or an error. stanza names. - Module composition validation rejects unknown modules, duplicate modules, incompatible report/module combinations, and invalid typed options. +- Source-oriented module builders omit missing optional current conditions, + forecast discussion, and weather story stanzas. +- Alert digest output distinguishes checked empty alert data from missing alert + source data. - Save failures include path and operation context. ## Tests @@ -83,6 +89,7 @@ Inspect: - `internal/briefing/three_day_test.go` - `internal/briefing/weekend_test.go` - `internal/briefing/storm_test.go` +- `internal/briefing/base_modules_test.go` - `internal/briefing/modules_test.go` - `internal/app/app_test.go` diff --git a/internal/briefing/base_modules.go b/internal/briefing/base_modules.go new file mode 100644 index 0000000..99faa4c --- /dev/null +++ b/internal/briefing/base_modules.go @@ -0,0 +1,236 @@ +package briefing + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +type MetadataModule struct { + RunID string `json:"run_id"` + ReportID report.ID `json:"report_id"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"prompt_id"` + GeneratedAt time.Time `json:"generated_at"` + Units string `json:"units"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"valid_period"` + Location *LocationContext `json:"location,omitempty"` + SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"` + Alerts *AlertDigestModule `json:"alerts,omitempty"` +} + +type SourceWarningSummary struct { + Source string `json:"source"` + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + CompletenessImpact string `json:"completeness_impact,omitempty"` +} + +type CurrentConditionsModule struct { + ConditionText string `json:"condition_text,omitempty"` + IsDay *bool `json:"is_day,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + TemperatureF *float64 `json:"temperature_f,omitempty"` + ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` + ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` + DewpointC *float64 `json:"dewpoint_c,omitempty"` + DewpointF *float64 `json:"dewpoint_f,omitempty"` + RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` + WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` + WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` + WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` +} + +type AlertDigestModule struct { + Checked bool `json:"checked"` + ActiveCount int `json:"active_count"` + RelevantCount int `json:"relevant_count"` + Missing bool `json:"missing,omitempty"` + Relevant []AlertSummary `json:"relevant,omitempty"` +} + +type AlertSummary struct { + Event string `json:"event,omitempty"` + Headline string `json:"headline,omitempty"` + Severity string `json:"severity,omitempty"` +} + +type AreaForecastDiscussionModule struct { + Product string `json:"product,omitempty"` + KeyMessages []string `json:"key_messages,omitempty"` + ShortTerm string `json:"short_term,omitempty"` + LongTerm string `json:"long_term,omitempty"` +} + +type WeatherStoryModule struct { + Available bool `json:"available"` + OfficeID string `json:"office_id,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + AltText string `json:"alt_text,omitempty"` + Priority bool `json:"priority"` + Order int `json:"order"` + DownloadURL string `json:"download_url,omitempty"` +} + +func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) { + metadata := ctx.Resolved.Metadata() + value := MetadataModule{ + RunID: metadata.RunID, + ReportID: metadata.ReportID, + Variant: variantForReport(metadata.ReportID), + PromptID: metadata.PromptID, + GeneratedAt: metadata.GeneratedAt, + Units: ctx.Units, + Timezone: ctx.Timezone, + ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(ctx.Location), + SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), + Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps), + } + return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil +} + +func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { + current := ctx.Collected.Current + if current == nil { + return nil, nil + } + value := CurrentConditionsModule{ + ConditionText: current.ConditionText, + IsDay: copyBool(current.IsDay), + TemperatureC: copyFloat(current.TemperatureC), + TemperatureF: copyFloat(current.TemperatureF), + ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), + ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), + DewpointC: copyFloat(current.DewpointC), + DewpointF: copyFloat(current.DewpointF), + RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), + WindSpeedKmh: copyFloat(current.WindSpeedKmh), + WindSpeedMph: copyFloat(current.WindSpeedMph), + WindDirectionDegrees: copyFloat(current.WindDirectionDegrees), + } + if value.isEmpty() { + return nil, nil + } + return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil +} + +func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { + value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps) + if value == nil { + value = &AlertDigestModule{} + } + return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil +} + +func buildAreaForecastDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) { + discussion := ctx.Collected.Discussion + if discussion == nil { + return nil, nil + } + value := AreaForecastDiscussionModule{ + Product: discussion.Product, + KeyMessages: append([]string(nil), discussion.KeyMessages...), + } + if discussion.ShortTerm != nil { + value.ShortTerm = discussion.ShortTerm.Text + } + if discussion.LongTerm != nil { + value.LongTerm = discussion.LongTerm.Text + } + if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" { + return nil, nil + } + return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil +} + +func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { + story := ctx.Collected.WeatherStory + if story == nil { + return nil, nil + } + value := WeatherStoryModule{ + Available: true, + OfficeID: story.OfficeID, + StartTime: story.StartTime, + EndTime: story.EndTime, + UpdatedAt: copyTime(story.UpdatedAt), + Title: story.Title, + Description: story.Description, + AltText: story.AltText, + Priority: story.Priority, + Order: story.Order, + DownloadURL: story.DownloadURL, + } + return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil +} + +func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary { + out := make([]SourceWarningSummary, 0, len(warnings)) + for _, warning := range warnings { + out = append(out, SourceWarningSummary{ + Source: warning.Source, + Code: warning.Code, + Severity: warning.Severity, + Message: warning.Message, + CompletenessImpact: warning.CompletenessImpact, + }) + } + return out +} + +func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule { + missing := sourceMissing(collected.SourceProvenance, "alerts") + if collected.Alerts == nil && !missing { + return nil + } + value := &AlertDigestModule{Missing: missing} + if collected.Alerts != nil { + value.Checked = true + value.ActiveCount = len(collected.Alerts.Alerts) + } + value.RelevantCount = len(overlaps) + for _, overlap := range overlaps { + value.Relevant = append(value.Relevant, AlertSummary{ + Event: overlap.Event, + Headline: overlap.Headline, + Severity: overlap.Severity, + }) + } + return value +} + +func sourceMissing(sources []weatherdata.Source, name string) bool { + for _, source := range sources { + if source.Name == name && source.Missing { + return true + } + } + return false +} + +func (v CurrentConditionsModule) isEmpty() bool { + return v.ConditionText == "" && + v.IsDay == nil && + v.TemperatureC == nil && + v.TemperatureF == nil && + v.ApparentTemperatureC == nil && + v.ApparentTemperatureF == nil && + v.DewpointC == nil && + v.DewpointF == nil && + v.RelativeHumidityPercent == nil && + v.WindSpeedKmh == nil && + v.WindSpeedMph == nil && + v.WindDirectionDegrees == nil +} diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go new file mode 100644 index 0000000..9031991 --- /dev/null +++ b/internal/briefing/base_modules_test.go @@ -0,0 +1,276 @@ +package briefing + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + tests := []struct { + id module.ID + stanza string + }{ + {id: module.Metadata, stanza: "metadata"}, + {id: module.CurrentConditions, stanza: "current_conditions"}, + {id: module.AlertDigest, stanza: "alert_digest"}, + {id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"}, + {id: module.WeatherStory, stanza: "weather_story"}, + } + for _, tt := range tests { + t.Run(string(tt.id), func(t *testing.T) { + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output == nil { + t.Fatal("BuildModule() output = nil, want stanza") + } + if output.ID != tt.id || output.StanzaName != tt.stanza { + t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza) + } + }) + } +} + +func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[MetadataModule](t, output) + if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { + t.Fatalf("metadata = %#v, want report identity", value) + } + if value.Location == nil || value.Location.Name != "Brentwood" { + t.Fatalf("Location = %#v, want configured location", value.Location) + } + if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" { + t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings) + } + if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 { + t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("Marshal metadata: %v", err) + } + jsonText := string(data) + if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") { + t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText) + } +} + +func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[CurrentConditionsModule](t, output) + if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { + t.Fatalf("CurrentConditions = %#v, want current condition facts", value) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("Marshal current conditions: %v", err) + } + jsonText := string(data) + for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("current json = %s, want field %s", jsonText, field) + } + } +} + +func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Collected.Alerts = &weatherdata.AlertRun{} + ctx.Derived.AlertOverlaps = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest}) + if err != nil { + t.Fatalf("BuildModule(checked empty) error = %v", err) + } + checked := moduleValue[AlertDigestModule](t, output) + if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing { + t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked) + } + + ctx.Collected.Alerts = nil + ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}} + output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest}) + if err != nil { + t.Fatalf("BuildModule(missing) error = %v", err) + } + missing := moduleValue[AlertDigestModule](t, output) + if missing.Checked || !missing.Missing { + t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing) + } +} + +func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Collected.Current = nil + ctx.Collected.Discussion = nil + ctx.Collected.WeatherStory = nil + + for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} { + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id}) + if err != nil { + t.Fatalf("BuildModule(%s) error = %v", id, err) + } + if output != nil { + t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output) + } + } +} + +func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion}) + if err != nil { + t.Fatalf("BuildModule(afd) error = %v", err) + } + afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput) + if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." { + t.Fatalf("AFD = %#v, want discussion sections", afd) + } + + storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory}) + if err != nil { + t.Fatalf("BuildModule(weather story) error = %v", err) + } + story := moduleValue[WeatherStoryModule](t, storyOutput) + if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { + t.Fatalf("WeatherStory = %#v, want structured story fields", story) + } + data, err := json.Marshal(storyOutput.Value) + if err != nil { + t.Fatalf("Marshal weather story: %v", err) + } + if !strings.Contains(string(data), "download_url") { + t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) + } +} + +func testModuleContext() ModuleContext { + generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") + definition := report.DefaultRegistry().MustLookup(report.DailyToday) + resolved := report.Resolved{ + Definition: definition, + GeneratedAt: generatedAt, + Timezone: "America/Chicago", + ValidPeriod: timeutil.Period{ + Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"), + End: mustParseModuleTime("2026-05-30T00:00:00-05:00"), + }, + } + isDay := true + tempF := 74.0 + apparentF := 76.0 + humidity := 71.0 + windMph := 8.0 + windDirection := 190.0 + updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") + return ModuleContext{ + Resolved: resolved, + Collected: facts.CollectedFacts{ + Current: &weatherdata.Current{ + ConditionText: "Partly cloudy", + IsDay: &isDay, + TemperatureF: &tempF, + ApparentTemperatureF: &apparentF, + RelativeHumidityPercent: &humidity, + WindSpeedMph: &windMph, + WindDirectionDegrees: &windDirection, + }, + Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`), + }}, + Discussion: &weatherdata.Discussion{ + Product: "discussion", + KeyMessages: []string{"Scattered showers are possible."}, + ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."}, + LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."}, + }, + WeatherStory: &weatherdata.WeatherStory{ + OfficeID: "LSX", + StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + UpdatedAt: &updatedAt, + Title: "Rain Chances", + Description: "Scattered showers are possible.", + AltText: "Weather story graphic with rain chances.", + Priority: true, + Order: 1, + DownloadURL: "https://example.invalid/story.png", + }, + SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}}, + SourceWarnings: []weatherdata.SourceWarning{{ + Source: "daily", + Code: "missing_source", + Severity: "warning", + Message: "daily source is missing", + Endpoint: "/forecast/daily", + CompletenessImpact: "source omitted", + }}, + }, + Derived: facts.DerivedFacts{ + AlertOverlaps: []forecast.AlertOverlap{{ + Event: "Flood Watch", + Headline: "Flooding possible", + Severity: "Moderate", + }}, + }, + Units: "us", + Timezone: "America/Chicago", + Location: &LocationContext{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + Timezone: "America/Chicago", + }, + } +} + +func moduleValue[T any](t *testing.T, output *module.Output) T { + t.Helper() + var value T + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("marshal module value: %v", err) + } + if err := json.Unmarshal(data, &value); err != nil { + t.Fatalf("decode module value: %v", err) + } + return value +} + +func mustParseModuleTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 4651f86..032f1fc 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -4,10 +4,22 @@ import ( "fmt" "reflect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) +type ModuleContext struct { + Resolved report.Resolved + Collected facts.CollectedFacts + Derived facts.DerivedFacts + Units string + Timezone string + Location *LocationContext +} + +type ModuleBuilder func(ModuleContext, any) (*module.Output, error) + type ModuleDefinition struct { ID module.ID StanzaName string @@ -17,6 +29,7 @@ type ModuleDefinition struct { SupportedReports []report.ID MissingData module.MissingDataBehavior AllowDuplicate bool + Builder ModuleBuilder } type ModuleRegistry struct { @@ -65,6 +78,40 @@ func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) { return definition, nil } +func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) { + definition, err := r.Lookup(item.ID) + if err != nil { + return nil, err + } + if !definition.SupportsReport(ctx.Resolved.Definition.ID) { + return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID) + } + if err := definition.ValidateOptions(item.Options); err != nil { + return nil, err + } + if definition.Builder == nil { + return nil, fmt.Errorf("module %q has no builder", item.ID) + } + options := item.Options + if options == nil { + options = definition.DefaultOptions + } + output, err := definition.Builder(ctx, options) + if err != nil { + return nil, err + } + if output == nil { + return nil, nil + } + if output.ID != definition.ID { + return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID) + } + if output.StanzaName != definition.StanzaName { + return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName) + } + return output, nil +} + func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error { seenModules := map[module.ID]struct{}{} seenStanzas := map[string]module.ID{} @@ -132,6 +179,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, + Builder: buildMetadataModule, }, { ID: module.CurrentConditions, @@ -140,6 +188,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions}, SupportedReports: allReports, MissingData: module.MissingDataOmit, + Builder: buildCurrentConditionsModule, }, { ID: module.DerivedDailySummary, @@ -181,6 +230,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, + Builder: buildAlertDigestModule, }, { ID: module.AreaForecastDiscussion, @@ -189,6 +239,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredCollected: []module.FactRequirement{module.CollectedDiscussion}, SupportedReports: allReports, MissingData: module.MissingDataOmit, + Builder: buildAreaForecastDiscussionModule, }, { ID: module.WeatherStory, @@ -197,6 +248,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory}, SupportedReports: allReports, MissingData: module.MissingDataOmit, + Builder: buildWeatherStoryModule, }, { ID: module.ForecastDelta,