Refactor the template variable framework
This commit is contained in:
@@ -10,38 +10,52 @@ Templates are Go `text/template` files. The current implemented template is:
|
|||||||
|
|
||||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
The hourly template is rendered from a curated `HourlyRenderContext`, not from
|
The hourly template is rendered from a structured `HourlyRenderContext`. Weather
|
||||||
the raw prompt data package. Weather data selection, derivation, module
|
data collection, derivation, module execution, generated text validation, and
|
||||||
execution, LLM generation, and artifact paths are handled before template
|
artifact paths are handled before template rendering.
|
||||||
rendering.
|
|
||||||
|
|
||||||
## Editing Rules
|
## Editing Rules
|
||||||
|
|
||||||
- Use Go `text/template` syntax.
|
- Use Go `text/template` syntax.
|
||||||
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
||||||
conditional display.
|
conditional display.
|
||||||
- Do not put weather derivation, source selection, or path construction logic
|
- Do not put weather derivation, source selection, or path construction logic in
|
||||||
in templates.
|
templates.
|
||||||
- Missing template keys are errors. A misspelled variable will fail rendering.
|
- Missing template keys are errors. A misspelled variable will fail rendering.
|
||||||
- No custom template functions are currently registered.
|
- 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 }}`.
|
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
|
||||||
|
|
||||||
## Hourly Template Variables
|
## Hourly Context
|
||||||
|
|
||||||
These are the complete variables currently available to
|
The hourly template receives five top-level values:
|
||||||
`internal/reporttemplate/templates/hourly.md.tmpl`.
|
|
||||||
|
|
||||||
### Report Metadata
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.ReportTitle` | string | Display title for the report. Currently `Hourly Report`. |
|
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
| `.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. Falls back to source location if configured location metadata is unavailable. |
|
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
|
||||||
| `.ValidPeriod` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
| `.GeneratedAt` | string | Friendly local generation time label. |
|
| `.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
|
These fields are written by Scriptorium as structured JSON, validated by
|
||||||
weatherreporter, and then inserted into the render context.
|
weatherreporter, and then inserted into the render context.
|
||||||
@@ -67,150 +81,163 @@ Example:
|
|||||||
{{ end }}
|
{{ 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 |
|
| 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
|
Common fields:
|
||||||
Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph.
|
|
||||||
|
| 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
|
### Hourly Forecast
|
||||||
|
|
||||||
|
Common period fields:
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.HourlyForecast` | []HourlyForecastRow | Ordered rows for the hourly report valid period. |
|
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods 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. |
|
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
|
||||||
| `.HourlyForecast[].Summary` | string | Hourly text description, falling back to the period name. |
|
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
|
||||||
| `.HourlyForecast[].Temperature` | string | Rounded temperature label such as `75 F`, or empty. |
|
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
|
||||||
| `.HourlyForecast[].Precipitation` | string | Rounded precipitation probability label such as `70% precipitation`, or empty. |
|
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
|
||||||
| `.HourlyForecast[].Wind` | string | Wind label such as `wind S 10 mph, gusts 18 mph`, or empty. |
|
| `.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:
|
Example:
|
||||||
|
|
||||||
```gotemplate
|
```gotemplate
|
||||||
## Hourly Forecast
|
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||||
{{ range .HourlyForecast }}
|
- {{ .PeriodBegins }}: {{ .TextDescription }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }}
|
||||||
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
- No hourly forecast rows available.
|
- No hourly forecast rows available.
|
||||||
{{ end }}
|
{{ end }}{{ end }}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Precipitation Timing
|
### Precipitation Timing
|
||||||
|
|
||||||
| Variable | Type | Description |
|
Common fields:
|
||||||
| --- | --- | --- |
|
|
||||||
| `.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
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| 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:
|
### Alert Digest
|
||||||
|
|
||||||
```gotemplate
|
|
||||||
## Alerts
|
|
||||||
{{ range .Alerts }}
|
|
||||||
- {{ . }}
|
|
||||||
{{ else }}
|
|
||||||
- No active alert overlaps for this report period.
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SPC Outlooks
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| 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:
|
### SPC Outlooks And Discussion
|
||||||
|
|
||||||
```text
|
|
||||||
Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forecast Discussion
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.ForecastDiscussion.KeyMessages` | []string | AFD key messages included for the hourly report. |
|
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
|
||||||
| `.ForecastDiscussion.ShortTerm` | string | AFD short-term discussion text, or empty if unavailable. |
|
| `.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:
|
### Area Forecast Discussion
|
||||||
|
|
||||||
```gotemplate
|
|
||||||
## Forecast Discussion
|
|
||||||
{{ range .ForecastDiscussion.KeyMessages }}
|
|
||||||
- {{ . }}
|
|
||||||
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
|
|
||||||
|
|
||||||
{{ . }}
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SPC Discussion
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.SPCDiscussions` | []string | SPC discussion labels retained for overlapping qualifying outlook periods. Empty when there is no relevant SPC discussion. |
|
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
|
||||||
Example:
|
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
|
||||||
```gotemplate
|
|
||||||
## SPC Discussion
|
|
||||||
{{ range .SPCDiscussions }}
|
|
||||||
- {{ . }}
|
|
||||||
{{ else }}
|
|
||||||
- No overlapping SPC discussion.
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Weather Story
|
### Weather Story
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| 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
|
- `.Collected` contains normalized source data and provenance from upstream
|
||||||
{{ with .GeneratedText.Confidence }}
|
Weather API fetches.
|
||||||
## Confidence
|
- `.Derived` contains shared slices and calculations used across modules, such
|
||||||
|
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
|
||||||
|
filtering inputs.
|
||||||
|
|
||||||
{{ . }}
|
These values are intentionally lower-level than `.Modules`. Use them when a
|
||||||
{{ end }}
|
template needs a specific field that is not exposed by a module, but keep
|
||||||
```
|
calculation-heavy changes in Go.
|
||||||
|
|
||||||
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 }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
@@ -229,4 +256,5 @@ git diff --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
Template render tests currently exercise the hourly template through
|
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`.
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
paths: paths,
|
paths: paths,
|
||||||
moduleSnapshot: moduleSnapshot,
|
moduleSnapshot: moduleSnapshot,
|
||||||
moduleSnapshotPath: moduleSnapshotPath,
|
moduleSnapshotPath: moduleSnapshotPath,
|
||||||
|
reportFacts: reportFacts,
|
||||||
dataPackage: dataPackage,
|
dataPackage: dataPackage,
|
||||||
dataPackagePath: dataPackagePath,
|
dataPackagePath: dataPackagePath,
|
||||||
briefingMetadata: briefingMetadata,
|
briefingMetadata: briefingMetadata,
|
||||||
@@ -638,6 +639,7 @@ type generatedReportRequest struct {
|
|||||||
paths state.ArtifactPaths
|
paths state.ArtifactPaths
|
||||||
moduleSnapshot module.Snapshot
|
moduleSnapshot module.Snapshot
|
||||||
moduleSnapshotPath string
|
moduleSnapshotPath string
|
||||||
|
reportFacts ReportFacts
|
||||||
dataPackage promptinput.Package
|
dataPackage promptinput.Package
|
||||||
dataPackagePath string
|
dataPackagePath string
|
||||||
briefingMetadata briefing.Metadata
|
briefingMetadata briefing.Metadata
|
||||||
@@ -690,7 +692,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
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 {
|
switch definition.TemplateID {
|
||||||
case "hourly":
|
case "hourly":
|
||||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly)
|
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,8 +450,20 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read render context: %v", err)
|
t.Fatalf("read render context: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) || !strings.Contains(string(renderContext), `"LocationName": "Brentwood, MO"`) {
|
for _, want := range []string{
|
||||||
t.Fatalf("render context missing deterministic fields:\n%s", string(renderContext))
|
`"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)
|
reportData, err := os.ReadFile(result.ReportPath)
|
||||||
if err != nil {
|
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) {
|
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) {
|
||||||
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
||||||
resolved.Definition.TemplateID = "missing-template"
|
resolved.Definition.TemplateID = "missing-template"
|
||||||
|
|||||||
@@ -738,7 +738,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|||||||
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
|
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
|
||||||
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||||
assertFileContains(t, validatedGeneratedTextPath, `"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")
|
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,44 +2,45 @@ package generatedtext
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"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/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HourlyRenderContext struct {
|
type HourlyRenderContext struct {
|
||||||
ReportTitle string
|
Report HourlyReportContext
|
||||||
LocationName string
|
GeneratedText Hourly
|
||||||
ValidPeriod string
|
Modules HourlyTemplateModules
|
||||||
GeneratedAt string
|
Collected facts.CollectedFacts
|
||||||
GeneratedText Hourly
|
Derived facts.DerivedFacts
|
||||||
CurrentConditions string
|
|
||||||
HourlyForecast []HourlyForecastRow
|
|
||||||
PrecipitationTiming string
|
|
||||||
Alerts []string
|
|
||||||
SPCOutlooks []string
|
|
||||||
ForecastDiscussion ForecastDiscussion
|
|
||||||
SPCDiscussions []string
|
|
||||||
WeatherStory string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HourlyForecastRow struct {
|
type HourlyReportContext struct {
|
||||||
Time string
|
Title string
|
||||||
Summary string
|
LocationName string
|
||||||
Temperature string
|
GeneratedAt time.Time
|
||||||
Precipitation string
|
GeneratedAtLabel string
|
||||||
Wind string
|
ValidPeriod timeutil.Period
|
||||||
|
ValidPeriodLabel string
|
||||||
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForecastDiscussion struct {
|
type HourlyTemplateModules struct {
|
||||||
KeyMessages []string
|
Metadata *briefing.MetadataModule
|
||||||
ShortTerm string
|
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)
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
|
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() {
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
||||||
}
|
}
|
||||||
|
modules, err := hourlyTemplateModules(snapshot)
|
||||||
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HourlyRenderContext{}, err
|
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{
|
return HourlyRenderContext{
|
||||||
ReportTitle: "Hourly Report",
|
Report: HourlyReportContext{
|
||||||
LocationName: locationName(metadata),
|
Title: "Hourly Report",
|
||||||
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
|
LocationName: locationName(metadata),
|
||||||
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
GeneratedText: generated,
|
GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location),
|
||||||
CurrentConditions: currentConditionsLabel(current),
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
HourlyForecast: hourlyForecastRows(hourly),
|
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
|
||||||
PrecipitationTiming: precipitationTimingLabel(precip),
|
Timezone: metadata.Timezone,
|
||||||
Alerts: alertLabels(alerts),
|
|
||||||
SPCOutlooks: outlookLabels(outlooks),
|
|
||||||
ForecastDiscussion: ForecastDiscussion{
|
|
||||||
KeyMessages: append([]string(nil), discussion.KeyMessages...),
|
|
||||||
ShortTerm: discussion.ShortTerm,
|
|
||||||
},
|
},
|
||||||
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
|
GeneratedText: generated,
|
||||||
WeatherStory: weatherStoryLabel(story),
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
value, ok, err := module.StanzaValue[T](snapshot, name)
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var zero T
|
return HourlyTemplateModules{}, err
|
||||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
|
||||||
}
|
}
|
||||||
if !ok {
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||||
var zero T
|
if err != nil {
|
||||||
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
|
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)
|
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var zero T
|
return nil, fmt.Errorf("build hourly render context: %w", err)
|
||||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
|
||||||
}
|
}
|
||||||
return value, nil
|
return &value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationName(metadata briefing.Metadata) string {
|
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 {
|
func timeLabel(value time.Time, location *time.Location) string {
|
||||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
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 ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"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/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||||
@@ -21,45 +23,53 @@ func TestBuildHourlyRenderContext(t *testing.T) {
|
|||||||
Impacts: "Brief downpours may slow travel.",
|
Impacts: "Brief downpours may slow travel.",
|
||||||
Confidence: "Medium confidence in timing.",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||||
}
|
}
|
||||||
if ctx.ReportTitle != "Hourly Report" {
|
if ctx.Report.Title != "Hourly Report" {
|
||||||
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
|
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
|
||||||
}
|
}
|
||||||
if ctx.LocationName != "Brentwood, MO" {
|
if ctx.Report.LocationName != "Brentwood, MO" {
|
||||||
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
|
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" {
|
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||||
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
|
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." {
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
||||||
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
|
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
||||||
}
|
}
|
||||||
if len(ctx.HourlyForecast) != 2 {
|
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
||||||
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
|
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" {
|
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("HourlyForecast[1] = %#v, want 10 AM showers row", row)
|
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") {
|
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
|
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
||||||
}
|
}
|
||||||
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
|
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
|
||||||
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
|
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" {
|
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
|
||||||
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
|
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
|
||||||
}
|
}
|
||||||
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||||
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
|
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
|
||||||
}
|
}
|
||||||
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
|
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
|
||||||
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
|
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
|
||||||
}
|
}
|
||||||
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
|
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
|
||||||
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
|
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)
|
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{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||||
Summary: "Storm chances increase.",
|
Summary: "Storm chances increase.",
|
||||||
Timing: "Late morning.",
|
Timing: "Late morning.",
|
||||||
Impacts: "Brief downpours.",
|
Impacts: "Brief downpours.",
|
||||||
})
|
}, testCollected(), facts.DerivedFacts{})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
|
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
|
if ctx.Modules.HourlyForecast != nil {
|
||||||
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
|
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 {
|
func testSnapshot(t *testing.T) module.Snapshot {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func TestTemplateLookup(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Template() error = %v", err)
|
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) {
|
if !strings.Contains(source, want) {
|
||||||
t.Fatalf("template missing %q:\n%s", want, source)
|
t.Fatalf("template missing %q:\n%s", want, source)
|
||||||
}
|
}
|
||||||
@@ -54,29 +54,58 @@ func TestSchemaLookup(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderHourly(t *testing.T) {
|
func TestRenderHourly(t *testing.T) {
|
||||||
rendered, err := Render("hourly", testRenderContext{
|
rendered, err := Render("hourly", testRenderContext{
|
||||||
ReportTitle: "Hourly Report",
|
Report: testReportContext{
|
||||||
LocationName: "Brentwood",
|
Title: "Hourly Report",
|
||||||
ValidPeriod: "May 29, 8:30 AM to 2:30 PM",
|
LocationName: "Brentwood",
|
||||||
GeneratedAt: "May 29, 8:30 AM",
|
ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM",
|
||||||
CurrentConditions: "74 F, light south wind.",
|
GeneratedAtLabel: "May 29, 8:30 AM",
|
||||||
PrecipitationTiming: "Showers are most likely late morning.",
|
},
|
||||||
WeatherStory: "Morning storms remain the main story.",
|
|
||||||
GeneratedText: testGeneratedText{
|
GeneratedText: testGeneratedText{
|
||||||
Summary: "Storm chances increase through late morning.",
|
Summary: "Storm chances increase through late morning.",
|
||||||
Timing: "The main window is 10 AM to noon.",
|
Timing: "The main window is 10 AM to noon.",
|
||||||
Impacts: "Brief downpours may slow travel.",
|
Impacts: "Brief downpours may slow travel.",
|
||||||
Confidence: "Medium confidence in timing.",
|
Confidence: "Medium confidence in timing.",
|
||||||
},
|
},
|
||||||
HourlyForecast: []testHourlyRow{
|
Modules: testModules{
|
||||||
{Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"},
|
CurrentConditions: &testCurrentConditions{
|
||||||
{Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"},
|
ConditionText: "Partly cloudy",
|
||||||
},
|
TemperatureF: floatPtr(74),
|
||||||
Alerts: []string{"Flood Watch until 2:30 PM"},
|
ApparentTemperatureF: floatPtr(76),
|
||||||
SPCOutlooks: []string{"Slight Risk through afternoon"},
|
RelativeHumidityPercent: floatPtr(71),
|
||||||
SPCDiscussions: []string{"Strong storms may develop late morning."},
|
WindDirection: "S",
|
||||||
ForecastDiscussion: testForecastDiscussion{
|
WindSpeedMph: floatPtr(8),
|
||||||
KeyMessages: []string{"Storms are most likely late morning."},
|
},
|
||||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
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 {
|
if err != nil {
|
||||||
@@ -88,8 +117,8 @@ func TestRenderHourly(t *testing.T) {
|
|||||||
"Valid: May 29, 8:30 AM to 2:30 PM",
|
"Valid: May 29, 8:30 AM to 2:30 PM",
|
||||||
"Storm chances increase through late morning.",
|
"Storm chances increase through late morning.",
|
||||||
"## Confidence",
|
"## Confidence",
|
||||||
"- 10 AM: Showers; 75 F; 70% showers; S 10 mph",
|
"- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph",
|
||||||
"- Flood Watch until 2:30 PM",
|
"- Flood Watch: Flood Watch until 2:30 PM (Moderate)",
|
||||||
"Short-term discussion favors increasing rain coverage.",
|
"Short-term discussion favors increasing rain coverage.",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(text, want) {
|
if !strings.Contains(text, want) {
|
||||||
@@ -126,7 +155,7 @@ func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderFailsForMissingContextFields(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 {
|
if err == nil {
|
||||||
t.Fatal("Render() error = nil, want missing field error")
|
t.Fatal("Render() error = nil, want missing field error")
|
||||||
}
|
}
|
||||||
@@ -136,19 +165,16 @@ func TestRenderFailsForMissingContextFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testRenderContext struct {
|
type testRenderContext struct {
|
||||||
ReportTitle string
|
Report testReportContext
|
||||||
LocationName string
|
GeneratedText testGeneratedText
|
||||||
ValidPeriod string
|
Modules testModules
|
||||||
GeneratedAt string
|
}
|
||||||
CurrentConditions string
|
|
||||||
PrecipitationTiming string
|
type testReportContext struct {
|
||||||
WeatherStory string
|
Title string
|
||||||
GeneratedText testGeneratedText
|
LocationName string
|
||||||
HourlyForecast []testHourlyRow
|
ValidPeriodLabel string
|
||||||
Alerts []string
|
GeneratedAtLabel string
|
||||||
SPCOutlooks []string
|
|
||||||
SPCDiscussions []string
|
|
||||||
ForecastDiscussion testForecastDiscussion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type testGeneratedText struct {
|
type testGeneratedText struct {
|
||||||
@@ -158,12 +184,76 @@ type testGeneratedText struct {
|
|||||||
Confidence string
|
Confidence string
|
||||||
}
|
}
|
||||||
|
|
||||||
type testHourlyRow struct {
|
type testModules struct {
|
||||||
Time string
|
CurrentConditions *testCurrentConditions
|
||||||
Summary string
|
HourlyForecast *testHourlyForecast
|
||||||
Temperature string
|
PrecipTiming *testPrecipTiming
|
||||||
Precipitation string
|
AlertDigest *testAlertDigest
|
||||||
Wind string
|
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 {
|
type testForecastDiscussion struct {
|
||||||
@@ -171,6 +261,30 @@ type testForecastDiscussion struct {
|
|||||||
ShortTerm string
|
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) {
|
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
previousIndex := -1
|
previousIndex := -1
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# {{ .ReportTitle }}
|
# {{ .Report.Title }}
|
||||||
|
|
||||||
{{ .LocationName }}
|
{{ .Report.LocationName }}
|
||||||
Valid: {{ .ValidPeriod }}
|
Valid: {{ .Report.ValidPeriodLabel }}
|
||||||
Generated: {{ .GeneratedAt }}
|
Generated: {{ .Report.GeneratedAtLabel }}
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -23,49 +23,78 @@ Generated: {{ .GeneratedAt }}
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
## Current Conditions
|
## Current Conditions
|
||||||
|
{{ with .Modules.CurrentConditions }}
|
||||||
{{ .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
|
## Hourly Forecast
|
||||||
{{ range .HourlyForecast }}
|
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||||
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
|
- {{ 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 }}
|
{{ else }}
|
||||||
- No hourly forecast rows available.
|
- No hourly forecast rows available.
|
||||||
|
{{ end }}{{ else }}
|
||||||
|
- No hourly forecast rows available.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
## Precipitation Timing
|
## Precipitation Timing
|
||||||
|
{{ with .Modules.PrecipTiming }}
|
||||||
{{ .PrecipitationTiming }}
|
{{ 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
|
## 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 }}
|
{{ else }}
|
||||||
- No active alert overlaps for this report period.
|
- No active alert overlaps for this report period.
|
||||||
|
{{ end }}{{ end }}{{ else }}
|
||||||
|
- No active alert overlaps for this report period.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
## SPC Outlooks
|
## 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 }}
|
{{ else }}
|
||||||
- No overlapping SPC outlooks.
|
- No overlapping SPC outlooks.
|
||||||
|
{{ end }}{{ else }}
|
||||||
|
- No overlapping SPC outlooks.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
## Forecast Discussion
|
## 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 }}
|
{{ end }}
|
||||||
|
|
||||||
## SPC Discussion
|
## SPC Discussion
|
||||||
{{ range .SPCDiscussions }}
|
{{ with .Modules.SPCConvectiveDiscussion }}{{ range .Discussions }}
|
||||||
- {{ . }}
|
- {{ if .Headline }}{{ .Headline }}{{ else }}{{ if .Summary }}{{ .Summary }}{{ else }}{{ .Discussion }}{{ end }}{{ end }}{{ with .Summary }}: {{ . }}{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
- No overlapping SPC discussion.
|
- No overlapping SPC discussion.
|
||||||
|
{{ end }}{{ else }}
|
||||||
|
- No overlapping SPC discussion.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
## Weather Story
|
## Weather Story
|
||||||
|
{{ with .Modules.WeatherStory }}{{ if .Available }}
|
||||||
{{ .WeatherStory }}
|
{{ with .Title }}{{ . }}{{ end }}{{ with .Description }} - {{ . }}{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
No weather story available.
|
||||||
|
{{ end }}{{ else }}
|
||||||
|
No weather story available.
|
||||||
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user