diff --git a/docs/templates.md b/docs/templates.md index b9a36eb..1f20ae3 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -10,38 +10,52 @@ Templates are Go `text/template` files. The current implemented template is: - `internal/reporttemplate/templates/hourly.md.tmpl` -The hourly template is rendered from a curated `HourlyRenderContext`, not from -the raw prompt data package. Weather data selection, derivation, module -execution, LLM generation, and artifact paths are handled before template -rendering. +The hourly template is rendered from a structured `HourlyRenderContext`. Weather +data collection, derivation, module execution, generated text validation, and +artifact paths are handled before template rendering. ## Editing Rules - Use Go `text/template` syntax. - Keep templates focused on Markdown layout, headings, ordering, and simple conditional display. -- Do not put weather derivation, source selection, or path construction logic - in templates. +- Do not put weather derivation, source selection, or path construction logic in + templates. - Missing template keys are errors. A misspelled variable will fail rendering. - No custom template functions are currently registered. -- Optional strings can be guarded with `{{ with .Field }}...{{ end }}`. +- Optional module stanzas are pointers and should be guarded with + `{{ with .Modules.WeatherStory }}...{{ end }}`. - Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`. -## Hourly Template Variables +## Hourly Context -These are the complete variables currently available to -`internal/reporttemplate/templates/hourly.md.tmpl`. - -### Report Metadata +The hourly template receives five top-level values: | Variable | Type | Description | | --- | --- | --- | -| `.ReportTitle` | string | Display title for the report. Currently `Hourly Report`. | -| `.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. Falls back to source location if configured location metadata is unavailable. | -| `.ValidPeriod` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. | -| `.GeneratedAt` | string | Friendly local generation time label. | +| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. | +| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. | +| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. | +| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. | +| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. | -### GeneratedText +Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are +available when a template needs lower-level facts, but templates should still +avoid nontrivial derivation. + +## Report + +| Variable | Type | Description | +| --- | --- | --- | +| `.Report.Title` | string | Display title. Currently `Hourly Report`. | +| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. | +| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. | +| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. | +| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. | +| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. | +| `.Report.Timezone` | string | Effective report timezone. | + +## GeneratedText These fields are written by Scriptorium as structured JSON, validated by weatherreporter, and then inserted into the render context. @@ -67,150 +81,163 @@ Example: {{ end }} ``` -### Current Conditions +## Modules + +`.Modules` exposes typed outputs from the same module pipeline used for the +prompt data package. Module fields are pointers because missing-data policy may +omit a stanza. | Variable | Type | Description | | --- | --- | --- | -| `.CurrentConditions` | string | Deterministic one-line current conditions summary. May include condition text, temperature, apparent temperature, humidity, and wind. | +| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. | +| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. | +| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. | +| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. | +| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. | +| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. | +| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. | +| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. | +| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. | -Example value: +### Current Conditions -```text -Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph. +Common fields: + +| Variable | Type | Description | +| --- | --- | --- | +| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. | +| `.Modules.CurrentConditions.TemperatureF` | *float64 | Current temperature. | +| `.Modules.CurrentConditions.ApparentTemperatureF` | *float64 | Apparent temperature. | +| `.Modules.CurrentConditions.RelativeHumidityPercent` | *float64 | Relative humidity. | +| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. | +| `.Modules.CurrentConditions.WindSpeedMph` | *float64 | Wind speed. | + +Example: + +```gotemplate +{{ with .Modules.CurrentConditions }} +{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }} +{{ else }} +No current conditions available. +{{ end }} ``` ### Hourly Forecast +Common period fields: + | Variable | Type | Description | | --- | --- | --- | -| `.HourlyForecast` | []HourlyForecastRow | Ordered rows for the hourly report valid period. | -| `.HourlyForecast[].Time` | string | Friendly local period start time, or the source period name if no start label is available. | -| `.HourlyForecast[].Summary` | string | Hourly text description, falling back to the period name. | -| `.HourlyForecast[].Temperature` | string | Rounded temperature label such as `75 F`, or empty. | -| `.HourlyForecast[].Precipitation` | string | Rounded precipitation probability label such as `70% precipitation`, or empty. | -| `.HourlyForecast[].Wind` | string | Wind label such as `wind S 10 mph, gusts 18 mph`, or empty. | +| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. | +| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. | +| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. | +| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. | +| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. | +| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. | +| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. | +| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. | +| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. | +| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. | Example: ```gotemplate -## Hourly Forecast -{{ range .HourlyForecast }} -- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }} +{{ with .Modules.HourlyForecast }}{{ range .Periods }} +- {{ .PeriodBegins }}: {{ .TextDescription }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }} {{ else }} - No hourly forecast rows available. -{{ end }} +{{ end }}{{ end }} ``` ### Precipitation Timing -| Variable | Type | Description | -| --- | --- | --- | -| `.PrecipitationTiming` | string | Deterministic precipitation timing summary. May include peak probability, precipitation windows, and thunder mention. | - -Example value: - -```text -Peak precipitation probability 70% at 10 AM; 2026-05-29 at 10:00 AM to 2026-05-29 at 12:00 PM (max 70% at 10 AM); Thunder is mentioned in the forecast. -``` - -### Alerts +Common fields: | Variable | Type | Description | | --- | --- | --- | -| `.Alerts` | []string | Alert labels for active alerts overlapping the report period. Empty when there are no relevant alert overlaps. | +| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. | +| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. | +| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. | +| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. | +| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. | +| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. | +| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. | +| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. | +| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. | -Example: - -```gotemplate -## Alerts -{{ range .Alerts }} -- {{ . }} -{{ else }} -- No active alert overlaps for this report period. -{{ end }} -``` - -### SPC Outlooks +### Alert Digest | Variable | Type | Description | | --- | --- | --- | -| `.SPCOutlooks` | []string | SPC convective outlook labels overlapping the report period. Empty when there are no overlapping outlooks. | +| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. | +| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. | +| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. | +| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. | +| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. | +| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. | +| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. | +| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. | -Example value: - -```text -Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM -``` - -### Forecast Discussion +### SPC Outlooks And Discussion | Variable | Type | Description | | --- | --- | --- | -| `.ForecastDiscussion.KeyMessages` | []string | AFD key messages included for the hourly report. | -| `.ForecastDiscussion.ShortTerm` | string | AFD short-term discussion text, or empty if unavailable. | +| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. | +| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. | +| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. | +| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. | +| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. | +| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. | +| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. | +| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. | +| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. | -Example: - -```gotemplate -## Forecast Discussion -{{ range .ForecastDiscussion.KeyMessages }} -- {{ . }} -{{ end }}{{ with .ForecastDiscussion.ShortTerm }} - -{{ . }} -{{ end }} -``` - -### SPC Discussion +### Area Forecast Discussion | Variable | Type | Description | | --- | --- | --- | -| `.SPCDiscussions` | []string | SPC discussion labels retained for overlapping qualifying outlook periods. Empty when there is no relevant SPC discussion. | - -Example: - -```gotemplate -## SPC Discussion -{{ range .SPCDiscussions }} -- {{ . }} -{{ else }} -- No overlapping SPC discussion. -{{ end }} -``` +| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. | +| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. | +| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. | +| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. | ### Weather Story | Variable | Type | Description | | --- | --- | --- | -| `.WeatherStory` | string | Weather story title and description, or `No weather story available.` | +| `.Modules.WeatherStory.Available` | bool | True when a story is available. | +| `.Modules.WeatherStory.OfficeID` | string | Source office ID. | +| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. | +| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. | +| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. | +| `.Modules.WeatherStory.Title` | string | Story title. | +| `.Modules.WeatherStory.Description` | string | Story description. | +| `.Modules.WeatherStory.AltText` | string | Story image alt text. | +| `.Modules.WeatherStory.Priority` | bool | Source priority flag. | +| `.Modules.WeatherStory.Order` | int | Source order. | +| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. | -## Common Patterns +## Collected And Derived Facts -Use `with` for optional strings: +The template also receives the full `facts.CollectedFacts` and +`facts.DerivedFacts` structs: -```gotemplate -{{ with .GeneratedText.Confidence }} -## Confidence +- `.Collected` contains normalized source data and provenance from upstream + Weather API fetches. +- `.Derived` contains shared slices and calculations used across modules, such + as valid-period hourly periods, precipitation timing, alert overlaps, and SPC + filtering inputs. -{{ . }} -{{ end }} -``` - -Use `range` with `else` for optional lists: - -```gotemplate -{{ range .SPCOutlooks }} -- {{ . }} -{{ else }} -- No overlapping SPC outlooks. -{{ end }} -``` - -Keep punctuation outside optional blocks when possible: - -```gotemplate -- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }} -``` +These values are intentionally lower-level than `.Modules`. Use them when a +template needs a specific field that is not exposed by a module, but keep +calculation-heavy changes in Go. ## Validation @@ -229,4 +256,5 @@ git diff --check ``` Template render tests currently exercise the hourly template through -`internal/generatedtext/render_context_test.go`. +`internal/generatedtext/render_context_test.go` and +`internal/reporttemplate/reporttemplate_test.go`. diff --git a/internal/app/app.go b/internal/app/app.go index 903582f..436a4a7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -559,6 +559,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro paths: paths, moduleSnapshot: moduleSnapshot, moduleSnapshotPath: moduleSnapshotPath, + reportFacts: reportFacts, dataPackage: dataPackage, dataPackagePath: dataPackagePath, briefingMetadata: briefingMetadata, @@ -638,6 +639,7 @@ type generatedReportRequest struct { paths state.ArtifactPaths moduleSnapshot module.Snapshot moduleSnapshotPath string + reportFacts ReportFacts dataPackage promptinput.Package dataPackagePath string briefingMetadata briefing.Metadata @@ -690,7 +692,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) return nil, err } - renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, hourlyText) + renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, hourlyText) if err != nil { return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err) } @@ -1117,10 +1119,10 @@ func validateGeneratedText(definition report.Definition, data []byte) (generated } } -func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, hourly generatedtext.Hourly) (any, error) { +func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, hourly generatedtext.Hourly) (any, error) { switch definition.TemplateID { case "hourly": - return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly) + return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived) default: return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 51db283..0eb5067 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -450,8 +450,20 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { if err != nil { t.Fatalf("read render context: %v", err) } - if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) || !strings.Contains(string(renderContext), `"LocationName": "Brentwood, MO"`) { - t.Fatalf("render context missing deterministic fields:\n%s", string(renderContext)) + for _, want := range []string{ + `"Report": {`, + `"Title": "Hourly Report"`, + `"LocationName": "Brentwood, MO"`, + `"GeneratedText": {`, + `"Modules": {`, + `"CurrentConditions": {`, + `"HourlyForecast": {`, + `"Collected": {`, + `"Derived": {`, + } { + if !strings.Contains(string(renderContext), want) { + t.Fatalf("render context missing %q:\n%s", want, string(renderContext)) + } } reportData, err := os.ReadFile(result.ReportPath) if err != nil { @@ -751,54 +763,6 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) { } } -func TestGenerateHourlyReportPreservesValidatedTextOnRenderContextFailure(t *testing.T) { - server := hourlyBundleServer(t) - cfg := hourlyGeneratedTextConfigWithModules(t, server, []string{ - "metadata", - "hourly_forecast", - "precip_timing", - "alert_digest", - "area_forecast_discussion", - "weather_story", - "spc_convective_outlooks", - }) - resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg) - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, - structuredRunBody: validHourlyGeneratedTextJSON(), - } - - _, err := GenerateReport(context.Background(), ReportRequest{ - Config: cfg, - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - assertGeneratedReportError(t, err, resolved, "build render context") - assertNoGeneratedFailureSideEffects(t, notifier, outputPath) - - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText) - assertPathsMissing(t, paths.RenderContext, paths.RenderedReport) - generatedText, readErr := os.ReadFile(paths.GeneratedText) - if readErr != nil { - t.Fatalf("read validated generated text: %v", readErr) - } - if string(generatedText) != validHourlyGeneratedTextJSON() { - t.Fatalf("validated generated text = %s, want normalized JSON", generatedText) - } - metadataData, readErr := os.ReadFile(paths.Metadata) - if readErr != nil { - t.Fatalf("read metadata: %v", readErr) - } - if !strings.Contains(string(metadataData), paths.GeneratedText) { - t.Fatalf("metadata missing validated generated text link:\n%s", string(metadataData)) - } -} - func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) resolved.Definition.TemplateID = "missing-template" diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 212d077..e30c1af 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -738,7 +738,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md") assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) - assertFileContains(t, renderContextPath, `"ReportTitle": "Hourly Report"`) + assertFileContains(t, renderContextPath, `"Report": {`) + assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`) + assertFileContains(t, renderContextPath, `"Modules": {`) + assertFileContains(t, renderContextPath, `"Collected": {`) + assertFileContains(t, renderContextPath, `"Derived": {`) assertFileContains(t, managedReportPath, "# Hourly Report") } diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index 6685135..1ee8f63 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -2,44 +2,45 @@ package generatedtext import ( "fmt" - "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type HourlyRenderContext struct { - ReportTitle string - LocationName string - ValidPeriod string - GeneratedAt string - GeneratedText Hourly - CurrentConditions string - HourlyForecast []HourlyForecastRow - PrecipitationTiming string - Alerts []string - SPCOutlooks []string - ForecastDiscussion ForecastDiscussion - SPCDiscussions []string - WeatherStory string + Report HourlyReportContext + GeneratedText Hourly + Modules HourlyTemplateModules + Collected facts.CollectedFacts + Derived facts.DerivedFacts } -type HourlyForecastRow struct { - Time string - Summary string - Temperature string - Precipitation string - Wind string +type HourlyReportContext struct { + Title string + LocationName string + GeneratedAt time.Time + GeneratedAtLabel string + ValidPeriod timeutil.Period + ValidPeriodLabel string + Timezone string } -type ForecastDiscussion struct { - KeyMessages []string - ShortTerm string +type HourlyTemplateModules struct { + Metadata *briefing.MetadataModule + CurrentConditions *briefing.CurrentConditionsModule + HourlyForecast *briefing.HourlyForecastModule + PrecipTiming *briefing.PrecipTimingModule + AlertDigest *briefing.AlertDigestModule + SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule + AreaForecastDiscussion *briefing.AreaForecastDiscussionModule + SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule + WeatherStory *briefing.WeatherStoryModule } -func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) { +func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) @@ -50,80 +51,87 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh if !metadata.ValidPeriod.IsValid() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required") } - - current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) + modules, err := hourlyTemplateModules(snapshot) if err != nil { return HourlyRenderContext{}, err } - hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) - if err != nil { - return HourlyRenderContext{}, err - } - precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) - if err != nil { - return HourlyRenderContext{}, err - } - alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) - if err != nil { - return HourlyRenderContext{}, err - } - outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) - if err != nil { - return HourlyRenderContext{}, err - } - discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) - if err != nil { - return HourlyRenderContext{}, err - } - spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) - if err != nil { - return HourlyRenderContext{}, err - } - story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) - if err != nil { - return HourlyRenderContext{}, err - } - return HourlyRenderContext{ - ReportTitle: "Hourly Report", - LocationName: locationName(metadata), - ValidPeriod: periodLabel(metadata.ValidPeriod, location), - GeneratedAt: timeLabel(metadata.GeneratedAt, location), - GeneratedText: generated, - CurrentConditions: currentConditionsLabel(current), - HourlyForecast: hourlyForecastRows(hourly), - PrecipitationTiming: precipitationTimingLabel(precip), - Alerts: alertLabels(alerts), - SPCOutlooks: outlookLabels(outlooks), - ForecastDiscussion: ForecastDiscussion{ - KeyMessages: append([]string(nil), discussion.KeyMessages...), - ShortTerm: discussion.ShortTerm, + Report: HourlyReportContext{ + Title: "Hourly Report", + LocationName: locationName(metadata), + GeneratedAt: metadata.GeneratedAt, + GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location), + ValidPeriod: metadata.ValidPeriod, + ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), + Timezone: metadata.Timezone, }, - SPCDiscussions: spcDiscussionLabels(spcDiscussion), - WeatherStory: weatherStoryLabel(story), + GeneratedText: generated, + Modules: modules, + Collected: collected, + Derived: derived, }, nil } -func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) { - value, ok, err := module.StanzaValue[T](snapshot, name) +func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { + metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) if err != nil { - var zero T - return zero, fmt.Errorf("build hourly render context: %w", err) + return HourlyTemplateModules{}, err } - if !ok { - var zero T - return zero, fmt.Errorf("build hourly render context requires stanza %q", name) + current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) + if err != nil { + return HourlyTemplateModules{}, err } - return value, nil + hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) + if err != nil { + return HourlyTemplateModules{}, err + } + precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) + if err != nil { + return HourlyTemplateModules{}, err + } + alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) + if err != nil { + return HourlyTemplateModules{}, err + } + outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) + if err != nil { + return HourlyTemplateModules{}, err + } + discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) + if err != nil { + return HourlyTemplateModules{}, err + } + spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) + if err != nil { + return HourlyTemplateModules{}, err + } + story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) + if err != nil { + return HourlyTemplateModules{}, err + } + return HourlyTemplateModules{ + Metadata: metadata, + CurrentConditions: current, + HourlyForecast: hourly, + PrecipTiming: precip, + AlertDigest: alerts, + SPCConvectiveOutlooks: outlooks, + AreaForecastDiscussion: discussion, + SPCConvectiveDiscussion: spcDiscussion, + WeatherStory: story, + }, nil } -func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) { +func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) { + output, ok := snapshot.LookupStanza(name) + if !ok || output.Value == nil { + return nil, nil + } value, _, err := module.StanzaValue[T](snapshot, name) if err != nil { - var zero T - return zero, fmt.Errorf("build hourly render context: %w", err) + return nil, fmt.Errorf("build hourly render context: %w", err) } - return value, nil + return &value, nil } func locationName(metadata briefing.Metadata) string { @@ -151,210 +159,3 @@ func periodLabel(period timeutil.Period, location *time.Location) string { func timeLabel(value time.Time, location *time.Location) string { return value.In(location).Format("2006-01-02 at 3:04 PM") } - -func currentConditionsLabel(current briefing.CurrentConditionsModule) string { - parts := []string{} - if current.ConditionText != "" { - parts = append(parts, current.ConditionText) - } - if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" { - parts = append(parts, temperature) - } - if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" { - parts = append(parts, "feels like "+apparent) - } - if current.RelativeHumidityPercent != nil { - parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent))) - } - if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" { - parts = append(parts, wind) - } - if len(parts) == 0 { - return "No current conditions available." - } - return strings.Join(parts, "; ") + "." -} - -func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow { - rows := make([]HourlyForecastRow, 0, len(hourly.Periods)) - for _, period := range hourly.Periods { - summary := period.TextDescription - if summary == "" { - summary = period.Name - } - rows = append(rows, HourlyForecastRow{ - Time: firstNonEmpty(period.PeriodBegins, period.Name), - Summary: summary, - Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC), - Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent), - Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh), - }) - } - return rows -} - -func precipitationTimingLabel(timing briefing.PrecipTimingModule) string { - parts := []string{} - if timing.MaxPopPercent != nil { - max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent) - if timing.MaxPopTime != "" { - max += " at " + timing.MaxPopTime - } - parts = append(parts, max) - } - for _, window := range timing.PrecipitationWindows { - label := window.PeriodBegins - if window.PeriodEnds != "" { - label += " to " + window.PeriodEnds - } - if window.MaxPopPercent != nil { - label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent) - if window.MaxPopTime != "" { - label += " at " + window.MaxPopTime - } - label += ")" - } - parts = append(parts, label) - } - if timing.ThunderMentioned { - parts = append(parts, "Thunder is mentioned in the forecast.") - } - if len(parts) == 0 { - return "No precipitation timing signal above threshold." - } - return strings.Join(parts, "; ") -} - -func alertLabels(alerts briefing.AlertDigestModule) []string { - if alerts.Missing { - return []string{"Alert source missing."} - } - out := make([]string, 0, len(alerts.Relevant)) - for _, alert := range alerts.Relevant { - main := firstNonEmpty(alert.Event, alert.Headline) - if main == "" { - continue - } - if alert.Headline != "" && alert.Headline != main { - main += ": " + alert.Headline - } - if alert.Severity != "" { - main += " (" + alert.Severity + ")" - } - out = append(out, main) - } - return out -} - -func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string { - out := make([]string, 0, len(outlooks.Outlooks)) - for _, outlook := range outlooks.Outlooks { - label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType) - if label == "" { - continue - } - if outlook.PeriodBegins != "" { - label += " from " + outlook.PeriodBegins - if outlook.PeriodEnds != "" { - label += " to " + outlook.PeriodEnds - } - } - out = append(out, label) - } - return out -} - -func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string { - out := make([]string, 0, len(discussion.Discussions)) - for _, record := range discussion.Discussions { - label := firstNonEmpty(record.Headline, record.Summary, record.Discussion) - if label == "" { - continue - } - if record.Summary != "" && record.Summary != label { - label += ": " + record.Summary - } - out = append(out, label) - } - return out -} - -func weatherStoryLabel(story briefing.WeatherStoryModule) string { - if !story.Available { - return "No weather story available." - } - parts := []string{} - if story.Title != "" { - parts = append(parts, story.Title) - } - if story.Description != "" { - parts = append(parts, story.Description) - } - if len(parts) == 0 { - return "Weather story is available." - } - return strings.Join(parts, " - ") -} - -func temperatureLabel(fahrenheit *float64, celsius *float64) string { - if fahrenheit != nil { - return fmt.Sprintf("%d F", rounded(*fahrenheit)) - } - if celsius != nil { - return fmt.Sprintf("%d C", rounded(*celsius)) - } - return "" -} - -func precipitationLabel(percent *float64) string { - if percent == nil { - return "" - } - return fmt.Sprintf("%d%% precipitation", rounded(*percent)) -} - -func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string { - speed := "" - if mph != nil { - speed = fmt.Sprintf("%d mph", rounded(*mph)) - } else if kmh != nil { - speed = fmt.Sprintf("%d km/h", rounded(*kmh)) - } - if direction != "" && speed != "" { - speed = direction + " " + speed - } else if direction != "" { - speed = direction + " wind" - } - gust := "" - if gustMph != nil { - gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph)) - } else if gustKmh != nil { - gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh)) - } - switch { - case speed != "" && gust != "": - return "wind " + speed + ", " + gust - case speed != "": - return "wind " + speed - case gust != "": - return "wind " + gust - default: - return "" - } -} - -func rounded(value float64) int { - if value < 0 { - return int(value - 0.5) - } - return int(value + 0.5) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 67e1cab..aabefd0 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -6,6 +6,8 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "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/reporttemplate" @@ -21,45 +23,53 @@ func TestBuildHourlyRenderContext(t *testing.T) { Impacts: "Brief downpours may slow travel.", Confidence: "Medium confidence in timing.", } - ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated) + collected := testCollected() + derived := testDerived() + ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived) if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) } - if ctx.ReportTitle != "Hourly Report" { - t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle) + if ctx.Report.Title != "Hourly Report" { + t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title) } - if ctx.LocationName != "Brentwood, MO" { - t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName) + if ctx.Report.LocationName != "Brentwood, MO" { + t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName) } - if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" { - t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod) + if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" { + t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel) } - if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." { - t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions) + if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 { + t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions) } - if len(ctx.HourlyForecast) != 2 { - t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast)) + if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 { + t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast) } - if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" { - t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row) + if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { + t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period) } - if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") { - t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming) + if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 { + t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming) } - if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" { - t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts) + if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" { + t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest) } - if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" { - t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks) + if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" { + t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks) } - if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." { - t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm) + if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." { + t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion) } - if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." { - t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions) + if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" { + t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion) } - if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." { - t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory) + if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" { + t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory) + } + if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) { + t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt) + } + if !ctx.Derived.PrecipTiming.ThunderMentioned { + t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true") } rendered, err := reporttemplate.Render("hourly", ctx) @@ -80,23 +90,23 @@ func TestBuildHourlyRenderContext(t *testing.T) { } } -func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) { +func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { snapshot, err := module.NewSnapshot([]module.Output{ {ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}}, }) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } - _, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ + ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ Summary: "Storm chances increase.", Timing: "Late morning.", Impacts: "Brief downpours.", - }) - if err == nil { - t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error") + }, testCollected(), facts.DerivedFacts{}) + if err != nil { + t.Fatalf("BuildHourlyRenderContext() error = %v", err) } - if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) { - t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err) + if ctx.Modules.HourlyForecast != nil { + t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast) } } @@ -120,6 +130,14 @@ func testMetadata() briefing.Metadata { } } +func testCollected() facts.CollectedFacts { + return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)} +} + +func testDerived() facts.DerivedFacts { + return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}} +} + func testSnapshot(t *testing.T) module.Snapshot { t.Helper() snapshot, err := module.NewSnapshot([]module.Output{ diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index e696527..456d72b 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -11,7 +11,7 @@ func TestTemplateLookup(t *testing.T) { if err != nil { t.Fatalf("Template() error = %v", err) } - for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} { + for _, want := range []string{"# {{ .Report.Title }}", "## Summary", "## Hourly Forecast", "## Weather Story"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } @@ -54,29 +54,58 @@ func TestSchemaLookup(t *testing.T) { func TestRenderHourly(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ - ReportTitle: "Hourly Report", - LocationName: "Brentwood", - ValidPeriod: "May 29, 8:30 AM to 2:30 PM", - GeneratedAt: "May 29, 8:30 AM", - CurrentConditions: "74 F, light south wind.", - PrecipitationTiming: "Showers are most likely late morning.", - WeatherStory: "Morning storms remain the main story.", + Report: testReportContext{ + Title: "Hourly Report", + LocationName: "Brentwood", + ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM", + GeneratedAtLabel: "May 29, 8:30 AM", + }, GeneratedText: testGeneratedText{ Summary: "Storm chances increase through late morning.", Timing: "The main window is 10 AM to noon.", Impacts: "Brief downpours may slow travel.", Confidence: "Medium confidence in timing.", }, - HourlyForecast: []testHourlyRow{ - {Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"}, - {Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"}, - }, - Alerts: []string{"Flood Watch until 2:30 PM"}, - SPCOutlooks: []string{"Slight Risk through afternoon"}, - SPCDiscussions: []string{"Strong storms may develop late morning."}, - ForecastDiscussion: testForecastDiscussion{ - KeyMessages: []string{"Storms are most likely late morning."}, - ShortTerm: "Short-term discussion favors increasing rain coverage.", + Modules: testModules{ + CurrentConditions: &testCurrentConditions{ + ConditionText: "Partly cloudy", + TemperatureF: floatPtr(74), + ApparentTemperatureF: floatPtr(76), + RelativeHumidityPercent: floatPtr(71), + WindDirection: "S", + WindSpeedMph: floatPtr(8), + }, + HourlyForecast: &testHourlyForecast{ + Periods: []testHourlyPeriod{ + {PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)}, + {PeriodBegins: "10 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), WindDirection: "S", WindSpeedMph: floatPtr(10)}, + }, + }, + PrecipTiming: &testPrecipTiming{ + MaxPopPercent: intPtr(70), + MaxPopTime: "10 AM", + PrecipitationWindows: []testPrecipWindow{ + {PeriodBegins: "10 AM", PeriodEnds: "12 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM"}, + }, + }, + AlertDigest: &testAlertDigest{ + Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}}, + }, + SPCConvectiveOutlooks: &testSPCOutlooks{ + Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, + }, + SPCConvectiveDiscussion: &testSPCDiscussion{ + Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}}, + }, + AreaForecastDiscussion: &testForecastDiscussion{ + KeyMessages: []string{"Storms are most likely late morning."}, + ShortTerm: "Short-term discussion favors increasing rain coverage.", + }, + WeatherStory: &testWeatherStory{ + Available: true, + Title: "Morning storms", + Description: "Morning storms remain the main story.", + }, }, }) if err != nil { @@ -88,8 +117,8 @@ func TestRenderHourly(t *testing.T) { "Valid: May 29, 8:30 AM to 2:30 PM", "Storm chances increase through late morning.", "## Confidence", - "- 10 AM: Showers; 75 F; 70% showers; S 10 mph", - "- Flood Watch until 2:30 PM", + "- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph", + "- Flood Watch: Flood Watch until 2:30 PM (Moderate)", "Short-term discussion favors increasing rain coverage.", } { if !strings.Contains(text, want) { @@ -126,7 +155,7 @@ func TestUnknownAssetsReturnActionableErrors(t *testing.T) { } func TestRenderFailsForMissingContextFields(t *testing.T) { - _, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"}) + _, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}}) if err == nil { t.Fatal("Render() error = nil, want missing field error") } @@ -136,19 +165,16 @@ func TestRenderFailsForMissingContextFields(t *testing.T) { } type testRenderContext struct { - ReportTitle string - LocationName string - ValidPeriod string - GeneratedAt string - CurrentConditions string - PrecipitationTiming string - WeatherStory string - GeneratedText testGeneratedText - HourlyForecast []testHourlyRow - Alerts []string - SPCOutlooks []string - SPCDiscussions []string - ForecastDiscussion testForecastDiscussion + Report testReportContext + GeneratedText testGeneratedText + Modules testModules +} + +type testReportContext struct { + Title string + LocationName string + ValidPeriodLabel string + GeneratedAtLabel string } type testGeneratedText struct { @@ -158,12 +184,76 @@ type testGeneratedText struct { Confidence string } -type testHourlyRow struct { - Time string - Summary string - Temperature string - Precipitation string - Wind string +type testModules struct { + CurrentConditions *testCurrentConditions + HourlyForecast *testHourlyForecast + PrecipTiming *testPrecipTiming + AlertDigest *testAlertDigest + SPCConvectiveOutlooks *testSPCOutlooks + AreaForecastDiscussion *testForecastDiscussion + SPCConvectiveDiscussion *testSPCDiscussion + WeatherStory *testWeatherStory +} + +type testCurrentConditions struct { + ConditionText string + TemperatureF *float64 + ApparentTemperatureF *float64 + RelativeHumidityPercent *float64 + WindSpeedMph *float64 + WindDirection string +} + +type testHourlyForecast struct { + Periods []testHourlyPeriod +} + +type testHourlyPeriod struct { + PeriodBegins string + Name string + TextDescription string + TemperatureF *float64 + WindSpeedMph *float64 + WindGustMph *float64 + WindDirection string + ProbabilityOfPrecipitationPercent *float64 +} + +type testPrecipTiming struct { + MaxPopPercent *int + MaxPopTime string + PrecipitationWindows []testPrecipWindow + ThunderMentioned bool +} + +type testPrecipWindow struct { + PeriodBegins string + PeriodEnds string + MaxPopPercent *int + MaxPopTime string +} + +type testAlertDigest struct { + Missing bool + Relevant []testAlert +} + +type testAlert struct { + Event string + Headline string + Severity string +} + +type testSPCOutlooks struct { + Outlooks []testSPCOutlook +} + +type testSPCOutlook struct { + Label string + LabelText string + OutlookType string + PeriodBegins string + PeriodEnds string } type testForecastDiscussion struct { @@ -171,6 +261,30 @@ type testForecastDiscussion struct { ShortTerm string } +type testSPCDiscussion struct { + Discussions []testSPCDiscussionRecord +} + +type testSPCDiscussionRecord struct { + Headline string + Summary string + Discussion string +} + +type testWeatherStory struct { + Available bool + Title string + Description string +} + +func floatPtr(value float64) *float64 { + return &value +} + +func intPtr(value int) *int { + return &value +} + func assertOrderedText(t *testing.T, text string, ordered []string) { t.Helper() previousIndex := -1 diff --git a/internal/reporttemplate/templates/hourly.md.tmpl b/internal/reporttemplate/templates/hourly.md.tmpl index 94a115b..d08dfb0 100644 --- a/internal/reporttemplate/templates/hourly.md.tmpl +++ b/internal/reporttemplate/templates/hourly.md.tmpl @@ -1,8 +1,8 @@ -# {{ .ReportTitle }} +# {{ .Report.Title }} -{{ .LocationName }} -Valid: {{ .ValidPeriod }} -Generated: {{ .GeneratedAt }} +{{ .Report.LocationName }} +Valid: {{ .Report.ValidPeriodLabel }} +Generated: {{ .Report.GeneratedAtLabel }} ## Summary @@ -23,49 +23,78 @@ Generated: {{ .GeneratedAt }} {{ end }} ## Current Conditions - -{{ .CurrentConditions }} +{{ with .Modules.CurrentConditions }} +{{ with .ConditionText }}{{ . }}{{ end }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ApparentTemperatureF }}; feels like {{ . }} F{{ end }}{{ with .RelativeHumidityPercent }}; humidity {{ . }}%{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}. +{{ else }} +No current conditions available. +{{ end }} ## Hourly Forecast -{{ range .HourlyForecast }} -- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }} +{{ with .Modules.HourlyForecast }}{{ range .Periods }} +- {{ if .PeriodBegins }}{{ .PeriodBegins }}{{ else }}{{ .Name }}{{ end }}: {{ if .TextDescription }}{{ .TextDescription }}{{ else }}{{ .Name }}{{ end }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }}{{ if .WindDirection }}; wind {{ .WindDirection }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}{{ else }}{{ with .WindSpeedMph }}; wind {{ . }} mph{{ end }}{{ end }}{{ with .WindGustMph }}, gusts {{ . }} mph{{ end }} {{ else }} - No hourly forecast rows available. +{{ end }}{{ else }} +- No hourly forecast rows available. {{ end }} ## Precipitation Timing - -{{ .PrecipitationTiming }} +{{ with .Modules.PrecipTiming }} +{{ with .MaxPopPercent }}Peak precipitation probability: {{ . }}%{{ with $.Modules.PrecipTiming.MaxPopTime }} at {{ . }}{{ end }}. +{{ end }}{{ range .PrecipitationWindows }}{{ $window := . }} +- Window: {{ .PeriodBegins }}{{ with .PeriodEnds }} to {{ . }}{{ end }}{{ with .MaxPopPercent }}; max {{ . }}%{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }} +{{ else }} +No precipitation windows above threshold. +{{ end }}{{ if .ThunderMentioned }} +Thunder is mentioned in the forecast. +{{ end }}{{ else }} +No precipitation timing signal above threshold. +{{ end }} ## Alerts -{{ range .Alerts }} -- {{ . }} +{{ with .Modules.AlertDigest }}{{ if .Missing }} +- Alert source missing. +{{ else }}{{ range .Relevant }} +- {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}{{ with .Headline }}: {{ . }}{{ end }}{{ with .Severity }} ({{ . }}){{ end }} {{ else }} - No active alert overlaps for this report period. +{{ end }}{{ end }}{{ else }} +- No active alert overlaps for this report period. {{ end }} ## SPC Outlooks -{{ range .SPCOutlooks }} -- {{ . }} +{{ with .Modules.SPCConvectiveOutlooks }}{{ range .Outlooks }} +- {{ if .LabelText }}{{ .LabelText }}{{ else }}{{ if .Label }}{{ .Label }}{{ else }}{{ .OutlookType }}{{ end }}{{ end }}{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }} {{ else }} - No overlapping SPC outlooks. +{{ end }}{{ else }} +- No overlapping SPC outlooks. {{ end }} ## Forecast Discussion -{{ range .ForecastDiscussion.KeyMessages }} +{{ with .Modules.AreaForecastDiscussion }}{{ range .KeyMessages }} - {{ . }} -{{ end }}{{ with .ForecastDiscussion.ShortTerm }} +{{ end }}{{ with .ShortTerm }} {{ . }} +{{ end }}{{ else }} +No area forecast discussion available. {{ end }} ## SPC Discussion -{{ range .SPCDiscussions }} -- {{ . }} +{{ with .Modules.SPCConvectiveDiscussion }}{{ range .Discussions }} +- {{ if .Headline }}{{ .Headline }}{{ else }}{{ if .Summary }}{{ .Summary }}{{ else }}{{ .Discussion }}{{ end }}{{ end }}{{ with .Summary }}: {{ . }}{{ end }} {{ else }} - No overlapping SPC discussion. +{{ end }}{{ else }} +- No overlapping SPC discussion. {{ end }} ## Weather Story - -{{ .WeatherStory }} +{{ with .Modules.WeatherStory }}{{ if .Available }} +{{ with .Title }}{{ . }}{{ end }}{{ with .Description }} - {{ . }}{{ end }} +{{ else }} +No weather story available. +{{ end }}{{ else }} +No weather story available. +{{ end }}