Refactor the template variable framework

This commit is contained in:
2026-06-14 08:57:53 -05:00
parent bb8de054dc
commit 28b8391d53
8 changed files with 512 additions and 552 deletions

View File

@@ -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`.

View File

@@ -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)
} }

View File

@@ -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"

View File

@@ -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")
} }

View File

@@ -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
ValidPeriod string
GeneratedAt string
GeneratedText Hourly GeneratedText Hourly
CurrentConditions string Modules HourlyTemplateModules
HourlyForecast []HourlyForecastRow Collected facts.CollectedFacts
PrecipitationTiming string Derived facts.DerivedFacts
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{
Title: "Hourly Report",
LocationName: locationName(metadata), LocationName: locationName(metadata),
ValidPeriod: periodLabel(metadata.ValidPeriod, location), GeneratedAt: metadata.GeneratedAt,
GeneratedAt: timeLabel(metadata.GeneratedAt, location), GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location),
GeneratedText: generated, ValidPeriod: metadata.ValidPeriod,
CurrentConditions: currentConditionsLabel(current), ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
HourlyForecast: hourlyForecastRows(hourly), Timezone: metadata.Timezone,
PrecipitationTiming: precipitationTimingLabel(precip),
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 ""
}

View File

@@ -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{

View File

@@ -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,30 +54,59 @@ 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{
Title: "Hourly Report",
LocationName: "Brentwood", LocationName: "Brentwood",
ValidPeriod: "May 29, 8:30 AM to 2:30 PM", ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM",
GeneratedAt: "May 29, 8:30 AM", GeneratedAtLabel: "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.",
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),
ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71),
WindDirection: "S",
WindSpeedMph: floatPtr(8),
}, },
Alerts: []string{"Flood Watch until 2:30 PM"}, HourlyForecast: &testHourlyForecast{
SPCOutlooks: []string{"Slight Risk through afternoon"}, Periods: []testHourlyPeriod{
SPCDiscussions: []string{"Strong storms may develop late morning."}, {PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)},
ForecastDiscussion: testForecastDiscussion{ {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."}, KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.", 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 {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -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
ValidPeriod string
GeneratedAt string
CurrentConditions string
PrecipitationTiming string
WeatherStory string
GeneratedText testGeneratedText GeneratedText testGeneratedText
HourlyForecast []testHourlyRow Modules testModules
Alerts []string }
SPCOutlooks []string
SPCDiscussions []string type testReportContext struct {
ForecastDiscussion testForecastDiscussion Title string
LocationName string
ValidPeriodLabel string
GeneratedAtLabel string
} }
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

View File

@@ -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 }}