diff --git a/docs/config.md b/docs/config.md index 1fb05a3..f631735 100644 --- a/docs/config.md +++ b/docs/config.md @@ -129,8 +129,8 @@ name the variable only; they should not contain the token value. - `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`. - `sources`: optional map of source-specific overrides, using the same policy values. -Hourly forecast data is required for generated reports. Optional sources and -stub source slots use the missing-source policy. +Hourly forecast data is required for generated reports. Optional sources use +the missing-source policy. ### `scriptorium` @@ -196,6 +196,7 @@ reports: deterministic_modules: - metadata - current_conditions + - narrative_forecast - id: area_forecast_discussion options: sections: @@ -209,8 +210,8 @@ combinations, duplicate stanza names, and invalid options fail config loading. includes all available AFD sections. The module registry accepts all module IDs documented in -[Module Contract Internals](internal/module.md). Modules without builders are -valid in composition but do not emit YAML stanzas. +[Module Contract Internals](internal/module.md). Unknown or unimplemented +module IDs fail validation instead of being skipped. ## Secrets diff --git a/docs/integrations/weatherapi.md b/docs/integrations/weatherapi.md index 50a4fca..0b4aea8 100644 --- a/docs/integrations/weatherapi.md +++ b/docs/integrations/weatherapi.md @@ -89,10 +89,6 @@ source-specific `missing_source.sources` policy: - `discussion` for `/discussion` - `weather_story` for `/weatherstories/latest` -The adapter also creates a missing stub source record for `daily` because that -source slot exists in the internal bundle but is not fetched from the Weather -API. - Policy behavior: - `error`: fail the fetch for that source diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index df69c72..11dbee5 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -27,15 +27,14 @@ Outputs: - `ModuleDefinition` values with module ID, stanza name, option type, supported reports, fact requirements, missing-data behavior, and builder - `module.Output` values for source-oriented stanzas: - `metadata`, `current_conditions`, `alert_digest`, + `metadata`, `current_conditions`, `narrative_forecast`, `alert_digest`, `area_forecast_discussion`, and `weather_story` - `module.Output` values for derived stanzas: `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `outdoor_windows`, and `tomorrow_planning` -The registry also contains accepted composition entries for modules that do not -emit stanzas until a builder exists. App orchestration skips those entries when -constructing snapshots. +Every registered composition entry has a builder. Unknown or unimplemented +module IDs fail validation instead of being skipped. ## Boundaries diff --git a/docs/internal/module.md b/docs/internal/module.md index 18ff5a8..4335fb9 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -31,6 +31,7 @@ The registry recognizes these IDs: - `metadata` - `current_conditions` +- `narrative_forecast` - `derived_daily_summary` - `derived_daypart_summaries` - `precip_timing` diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 4e3bb5f..3fcc84b 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -39,6 +39,7 @@ report: briefing: metadata: {} current_conditions: {} + narrative_forecast: {} recent_changes: items: [] ``` diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index c916873..48c40e1 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -22,7 +22,6 @@ Outputs: - `weatherdata.Bundle` with observation, current conditions, hourly forecast, narrative forecast, active alerts, discussion, latest weather story, source records, and source warnings -- stub source record for the daily forecast source slot - optional saved bundle JSON through app fetch helpers ## Boundaries @@ -70,7 +69,7 @@ data is required and cannot be skipped. - HTTP errors, response read failures, and envelope decode failures include endpoint context. - Missing hourly data or hourly forecasts with no periods fail bundle fetch. -- Optional and stub sources follow missing-source policy. +- Optional sources follow missing-source policy. - Explicit `data: null` from `/alerts/active` produces an empty, non-missing alert run. diff --git a/examples/config.yml b/examples/config.yml index 260dcc0..f1697d1 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -72,6 +72,7 @@ reports: deterministic_modules: - metadata - current_conditions + - narrative_forecast - derived_daily_summary - derived_daypart_summaries - precip_timing diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index d83cb81..614e618 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -112,9 +112,6 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) { if err := builder.fetchWeatherStory(ctx); err != nil { return nil, err } - if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil { - return nil, err - } return builder.bundle, nil } @@ -267,15 +264,6 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { return nil } -func (b *bundleBuilder) addStub(sourceName string, message string) error { - source := weatherdata.Source{ - Name: sourceName, - FetchedAt: b.fetchedAt, - Missing: true, - } - return b.applyMissingPolicy(&source, "missing_source", message) -} - func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { source.Missing = true if required { diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index 5c4fabb..98e990a 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -55,11 +55,11 @@ func TestFetchBundleFromFixtures(t *testing.T) { if bundle.WeatherStory.UpdatedAt == nil { t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") } - if len(bundle.Sources) != 8 { - t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) + if len(bundle.Sources) != 7 { + t.Fatalf("Sources length = %d, want 7", len(bundle.Sources)) } - if len(bundle.Warnings) != 1 { - t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings)) + if len(bundle.Warnings) != 0 { + t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) } if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) @@ -197,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) { wantWarns int wantSource bool }{ - {name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true}, + {name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true}, {name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true}, {name: "error", policy: config.MissingSourceError, wantErr: true}, } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f8ac846..cb4dec7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -161,9 +161,19 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "recent_changes:") || !strings.Contains(string(data), "current_conditions:") || + !strings.Contains(string(data), "narrative_forecast:") || !strings.Contains(string(data), "area_forecast_discussion:") { t.Fatalf("data package missing expected content:\n%s", string(data)) } + if strings.Contains(string(data), "source_warnings:") { + t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data)) + } + currentIndex := strings.Index(string(data), " current_conditions:") + narrativeIndex := strings.Index(string(data), " narrative_forecast:") + summaryIndex := strings.Index(string(data), " derived_daily_summary:") + if currentIndex < 0 || narrativeIndex < 0 || summaryIndex < 0 || !(currentIndex < narrativeIndex && narrativeIndex < summaryIndex) { + t.Fatalf("data package stanza order is wrong, want current_conditions then narrative_forecast then derived_daily_summary:\n%s", string(data)) + } savedDataPackage, err := promptinput.LoadYAML(data) if err != nil { t.Fatalf("decode data package: %v", err) @@ -178,6 +188,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !ok || current["condition_text"] != "Clear" { t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"]) } + narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any) + if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") { + t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"]) + } story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any) if !ok || story["title"] != "Several Chances for Rain Through Monday" { t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) @@ -954,8 +968,11 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) { if err != nil { t.Fatalf("InspectSources() error = %v", err) } - if len(sources.Sources) == 0 || len(sources.Warnings) == 0 { - t.Fatalf("sources = %#v, want provenance and warnings", sources) + if len(sources.Sources) == 0 { + t.Fatalf("sources = %#v, want provenance", sources) + } + if len(sources.Warnings) != 0 { + t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings) } } diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 94f6b55..fc77b50 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -24,6 +24,7 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) { }{ {id: module.Metadata, stanza: "metadata"}, {id: module.CurrentConditions, stanza: "current_conditions"}, + {id: module.NarrativeForecast, stanza: "narrative_forecast"}, {id: module.AlertDigest, stanza: "alert_digest"}, {id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"}, {id: module.WeatherStory, stanza: "weather_story"}, @@ -44,6 +45,51 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) { } } +func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[NarrativeForecastModule](t, output) + if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 { + t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value) + } + period := value.Periods[0] + if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { + t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) + } + if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { + t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("Marshal narrative forecast: %v", err) + } + jsonText := string(data) + for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "probability_of_precipitation_percent"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("narrative json = %s, want field %s", jsonText, field) + } + } + if strings.Contains(jsonText, "Tomorrow night") { + t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) + } +} + +func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend) + + _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast}) + if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) { + t.Fatalf("BuildModule() error = %v, want incompatible report", err) + } +} + func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() @@ -130,10 +176,12 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.Current = nil + ctx.Collected.Narrative = nil + ctx.Derived.ValidPeriodNarrativePeriods = nil ctx.Collected.Discussion = nil ctx.Collected.WeatherStory = nil - for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} { + for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.AreaForecastDiscussion, module.WeatherStory} { output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id}) if err != nil { t.Fatalf("BuildModule(%s) error = %v", id, err) @@ -212,6 +260,9 @@ func testModuleContext() ModuleContext { humidity := 71.0 windMph := 8.0 windDirection := 190.0 + narrativeTempF := 81.0 + narrativePop := 60.0 + narrativeWind := 12.0 updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") return ModuleContext{ Resolved: resolved, @@ -225,6 +276,25 @@ func testModuleContext() ModuleContext { WindSpeedMph: &windMph, WindDirectionDegrees: &windDirection, }, + Narrative: &weatherdata.ForecastRun{ + LocationID: "test-grid", + LocationName: "Testville", + IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), + UpdatedAt: &updatedAt, + Product: "narrative", + Periods: []weatherdata.ForecastPeriod{ + { + Name: "Today", + StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + IsDay: &isDay, + TextDescription: "Morning storms, then partly sunny.", + TemperatureF: floatPtr(narrativeTempF), + WindSpeedMph: &narrativeWind, + ProbabilityOfPrecipitationPercent: &narrativePop, + }, + }, + }, Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`), }}, @@ -257,6 +327,18 @@ func testModuleContext() ModuleContext { }}, }, Derived: facts.DerivedFacts{ + ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{ + { + Name: "Today", + StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + IsDay: &isDay, + TextDescription: "Morning storms, then partly sunny.", + TemperatureF: floatPtr(narrativeTempF), + WindSpeedMph: &narrativeWind, + ProbabilityOfPrecipitationPercent: &narrativePop, + }, + }, AlertOverlaps: []forecast.AlertOverlap{{ Event: "Flood Watch", Headline: "Flooding possible", diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index e1f0fb7..87bc74c 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -194,6 +194,15 @@ func derivedModuleContext(id report.ID) ModuleContext { derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42), derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30), } + narrative := []weatherdata.ForecastPeriod{ + { + Name: "Today", + StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + TextDescription: "Morning storms, then partly sunny.", + TemperatureF: floatPtr(81), + }, + } summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}} return ModuleContext{ Resolved: report.Resolved{ @@ -202,11 +211,19 @@ func derivedModuleContext(id report.ID) ModuleContext { Timezone: "America/Chicago", ValidPeriod: summary.Period, }, + Collected: facts.CollectedFacts{ + Narrative: &weatherdata.ForecastRun{ + IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), + Product: "narrative", + Periods: append([]weatherdata.ForecastPeriod(nil), narrative...), + }, + }, Derived: facts.DerivedFacts{ - ValidPeriodHourlyPeriods: hours, - DailySummaries: []forecast.DailySummary{summary}, - DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), - PrecipTiming: forecast.BuildPrecipTiming(hours), + ValidPeriodHourlyPeriods: hours, + ValidPeriodNarrativePeriods: narrative, + DailySummaries: []forecast.DailySummary{summary}, + DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), + PrecipTiming: forecast.BuildPrecipTiming(hours), }, Units: "us", Timezone: "America/Chicago", diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 9edbeae..e94d5e3 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -152,6 +152,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex switch requirement { case module.CollectedCurrentConditions: return ctx.Collected.Current != nil + case module.CollectedNarrativeForecast: + return ctx.Collected.Narrative != nil case module.CollectedAlerts: return ctx.Collected.Alerts != nil case module.CollectedDiscussion: @@ -262,6 +264,16 @@ func defaultModuleDefinitions() []ModuleDefinition { MissingData: module.MissingDataOmit, Builder: buildCurrentConditionsModule, }, + { + ID: module.NarrativeForecast, + StanzaName: "narrative_forecast", + DefaultOptions: module.NarrativeForecastOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, + SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, + MissingData: module.MissingDataOmit, + Builder: buildNarrativeForecastModule, + }, { ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", diff --git a/internal/briefing/narrative_forecast_module.go b/internal/briefing/narrative_forecast_module.go new file mode 100644 index 0000000..411f8c2 --- /dev/null +++ b/internal/briefing/narrative_forecast_module.go @@ -0,0 +1,91 @@ +package briefing + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +type NarrativeForecastModule struct { + Product string `json:"product,omitempty"` + IssuedAt time.Time `json:"issued_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SourceLocation string `json:"source_location,omitempty"` + SourceLocationID string `json:"source_location_id,omitempty"` + Periods []NarrativeForecastPeriod `json:"periods,omitempty"` +} + +type NarrativeForecastPeriod struct { + Name string `json:"name,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + IsDay *bool `json:"is_day,omitempty"` + TextDescription string `json:"text_description,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + TemperatureF *float64 `json:"temperature_f,omitempty"` + TemperatureCMin *float64 `json:"temperature_c_min,omitempty"` + TemperatureFMin *float64 `json:"temperature_f_min,omitempty"` + TemperatureCMax *float64 `json:"temperature_c_max,omitempty"` + TemperatureFMax *float64 `json:"temperature_f_max,omitempty"` + WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` + WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` + WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"` + WindGustMph *float64 `json:"wind_gust_mph,omitempty"` + WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` + ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"` +} + +func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) { + narrative := ctx.Collected.Narrative + if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 { + return nil, nil + } + value := NarrativeForecastModule{ + Product: narrative.Product, + IssuedAt: narrative.IssuedAt, + UpdatedAt: copyTime(narrative.UpdatedAt), + SourceLocation: narrative.LocationName, + SourceLocationID: narrative.LocationID, + Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods), + } + if value.isEmpty() { + return nil, nil + } + return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil +} + +func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeForecastPeriod { + out := make([]NarrativeForecastPeriod, 0, len(periods)) + for _, period := range periods { + out = append(out, NarrativeForecastPeriod{ + Name: period.Name, + StartTime: period.StartTime, + EndTime: period.EndTime, + IsDay: copyBool(period.IsDay), + TextDescription: period.TextDescription, + TemperatureC: copyFloat(period.TemperatureC), + TemperatureF: copyFloat(period.TemperatureF), + TemperatureCMin: copyFloat(period.TemperatureCMin), + TemperatureFMin: copyFloat(period.TemperatureFMin), + TemperatureCMax: copyFloat(period.TemperatureCMax), + TemperatureFMax: copyFloat(period.TemperatureFMax), + WindSpeedKmh: copyFloat(period.WindSpeedKmh), + WindSpeedMph: copyFloat(period.WindSpeedMph), + WindGustKmh: copyFloat(period.WindGustKmh), + WindGustMph: copyFloat(period.WindGustMph), + WindDirectionDegrees: copyFloat(period.WindDirectionDegrees), + ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), + }) + } + return out +} + +func (v NarrativeForecastModule) isEmpty() bool { + return v.Product == "" && + v.IssuedAt.IsZero() && + v.UpdatedAt == nil && + v.SourceLocation == "" && + v.SourceLocationID == "" && + len(v.Periods) == 0 +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ab2f0f8..dc9fd44 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -736,8 +736,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) { sourcesOutput = stdout.String() } } - if !strings.Contains(sourcesOutput, `"warnings"`) { - t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput) + if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { + t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) } } diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index bbd4254..27eda20 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -17,7 +17,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { Current: &weatherdata.Current{ConditionText: "Clear"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"}, Sources: []weatherdata.Source{{Name: "hourly"}}, - Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}}, + Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, } collected := BuildCollected(bundle) @@ -27,13 +27,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) } - if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" { - t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings) + if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" { + t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings) } bundle.Sources[0].Name = "changed" bundle.Warnings[0].Source = "changed" - if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" { + if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) } } diff --git a/internal/module/module.go b/internal/module/module.go index c6792c0..7173863 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -13,6 +13,7 @@ type ID string const ( Metadata ID = "metadata" CurrentConditions ID = "current_conditions" + NarrativeForecast ID = "narrative_forecast" DerivedDailySummary ID = "derived_daily_summary" DerivedDaypartSummaries ID = "derived_daypart_summaries" PrecipTiming ID = "precip_timing" @@ -104,6 +105,7 @@ type FactRequirement string const ( CollectedCurrentConditions FactRequirement = "collected.current_conditions" + CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" CollectedAlerts FactRequirement = "collected.alerts" CollectedDiscussion FactRequirement = "collected.discussion" CollectedWeatherStory FactRequirement = "collected.weather_story" @@ -127,6 +129,7 @@ const ( type MetadataOptions struct{} type CurrentConditionsOptions struct{} +type NarrativeForecastOptions struct{} type DerivedDailySummaryOptions struct{} type DerivedDaypartSummariesOptions struct{} type PrecipTimingOptions struct{} diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 0651dd9..00a838e 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -41,6 +41,7 @@ func dailyTodayModules() []module.ConfigItem { return moduleItems( module.Metadata, module.CurrentConditions, + module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming, diff --git a/internal/report/period_test.go b/internal/report/period_test.go index 3cc249a..bccb920 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -262,6 +262,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { want: []module.ID{ module.Metadata, module.CurrentConditions, + module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming, @@ -276,6 +277,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { want: []module.ID{ module.Metadata, module.CurrentConditions, + module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming,