Add hourly generated text contract
This commit is contained in:
73
docs/internal/generatedtext.md
Normal file
73
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Generated Text Internals
|
||||
|
||||
This document describes structured generated-text handling in
|
||||
`internal/generatedtext`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/generatedtext` validates structured text returned for
|
||||
generated-text-template reports and builds curated render contexts for
|
||||
templates. The first implemented contract is the Hourly Report.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- raw Hourly Report GeneratedText JSON
|
||||
- report metadata from `internal/briefing`
|
||||
- a module snapshot from `internal/module`
|
||||
- validated hourly generated text
|
||||
|
||||
Outputs:
|
||||
|
||||
- typed `Hourly` generated text
|
||||
- normalized stable JSON for validated hourly generated text
|
||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||
|
||||
The hourly generated text JSON accepts:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "string",
|
||||
"timing": "string",
|
||||
"impacts": "string",
|
||||
"confidence": "string"
|
||||
}
|
||||
```
|
||||
|
||||
`summary`, `timing`, and `impacts` are required after trimming whitespace.
|
||||
`confidence` is optional and omitted from normalized JSON when blank.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns typed generated-text validation and render-context shaping.
|
||||
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
||||
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||
definitions, compare snapshots, or render templates directly in production
|
||||
workflows.
|
||||
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
||||
limited to typed JSON decoding, unknown-field rejection, and required-field
|
||||
checks.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Malformed generated-text JSON fails with decode context.
|
||||
- Unknown generated-text JSON fields fail during decoding.
|
||||
- Empty required hourly fields fail after trimming whitespace.
|
||||
- Missing required render-context stanzas fail with the stanza name.
|
||||
- Invalid render metadata, including missing timezone, missing generated time,
|
||||
or invalid valid period, fails before template rendering.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/generatedtext/hourly_test.go`
|
||||
- `internal/generatedtext/render_context_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Render contexts are curated structs, not raw prompt-input packages.
|
||||
- Required generated text is normalized before downstream artifact storage.
|
||||
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||
context fields rather than forcing raw module data into templates.
|
||||
55
internal/generatedtext/hourly.go
Normal file
55
internal/generatedtext/hourly.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Package generatedtext validates structured LLM text and render contexts.
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Hourly struct {
|
||||
Summary string `json:"summary"`
|
||||
Timing string `json:"timing"`
|
||||
Impacts string `json:"impacts"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
func ValidateHourly(data []byte) (Hourly, []byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var value Hourly
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if err != io.EOF {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||
}
|
||||
} else {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
|
||||
}
|
||||
|
||||
value.Summary = strings.TrimSpace(value.Summary)
|
||||
value.Timing = strings.TrimSpace(value.Timing)
|
||||
value.Impacts = strings.TrimSpace(value.Impacts)
|
||||
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||
if value.Summary == "" {
|
||||
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
|
||||
}
|
||||
if value.Timing == "" {
|
||||
return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required")
|
||||
}
|
||||
if value.Impacts == "" {
|
||||
return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required")
|
||||
}
|
||||
|
||||
normalized, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err)
|
||||
}
|
||||
return value, normalized, nil
|
||||
}
|
||||
86
internal/generatedtext/hourly_test.go
Normal file
86
internal/generatedtext/hourly_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateHourlyNormalizesJSON(t *testing.T) {
|
||||
value, normalized, err := ValidateHourly([]byte(`{
|
||||
"timing": " main window late morning ",
|
||||
"summary": " Storm chances increase. ",
|
||||
"confidence": " Medium ",
|
||||
"impacts": " Brief downpours. "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHourly() error = %v", err)
|
||||
}
|
||||
if value.Summary != "Storm chances increase." {
|
||||
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||
}
|
||||
want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","confidence":"Medium"}`
|
||||
if string(normalized) != want {
|
||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) {
|
||||
_, normalized, err := ValidateHourly([]byte(`{
|
||||
"summary": "Storm chances increase.",
|
||||
"timing": "Late morning.",
|
||||
"impacts": "Brief downpours.",
|
||||
"confidence": " "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHourly() error = %v", err)
|
||||
}
|
||||
want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`
|
||||
if string(normalized) != want {
|
||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "malformed",
|
||||
in: `{`,
|
||||
want: "decode hourly generated text",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`,
|
||||
want: `unknown field "extra"`,
|
||||
},
|
||||
{
|
||||
name: "missing summary",
|
||||
in: `{"timing":"Late morning.","impacts":"Brief downpours."}`,
|
||||
want: "summary is required",
|
||||
},
|
||||
{
|
||||
name: "blank timing",
|
||||
in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`,
|
||||
want: "timing is required",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`,
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, err := ValidateHourly([]byte(test.in))
|
||||
if err == nil {
|
||||
t.Fatal("ValidateHourly() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ValidateHourly() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
360
internal/generatedtext/render_context.go
Normal file
360
internal/generatedtext/render_context.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type HourlyRenderContext struct {
|
||||
ReportTitle string
|
||||
LocationName string
|
||||
ValidPeriod string
|
||||
GeneratedAt string
|
||||
GeneratedText Hourly
|
||||
CurrentConditions string
|
||||
HourlyForecast []HourlyForecastRow
|
||||
PrecipitationTiming string
|
||||
Alerts []string
|
||||
SPCOutlooks []string
|
||||
ForecastDiscussion ForecastDiscussion
|
||||
SPCDiscussions []string
|
||||
WeatherStory string
|
||||
}
|
||||
|
||||
type HourlyForecastRow struct {
|
||||
Time string
|
||||
Summary string
|
||||
Temperature string
|
||||
Precipitation string
|
||||
Wind string
|
||||
}
|
||||
|
||||
type ForecastDiscussion struct {
|
||||
KeyMessages []string
|
||||
ShortTerm string
|
||||
}
|
||||
|
||||
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) {
|
||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
|
||||
}
|
||||
if metadata.GeneratedAt.IsZero() {
|
||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
|
||||
}
|
||||
if !metadata.ValidPeriod.IsValid() {
|
||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
||||
}
|
||||
|
||||
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
|
||||
return HourlyRenderContext{
|
||||
ReportTitle: "Hourly Report",
|
||||
LocationName: locationName(metadata),
|
||||
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
|
||||
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
|
||||
GeneratedText: generated,
|
||||
CurrentConditions: currentConditionsLabel(current),
|
||||
HourlyForecast: hourlyForecastRows(hourly),
|
||||
PrecipitationTiming: precipitationTimingLabel(precip),
|
||||
Alerts: alertLabels(alerts),
|
||||
SPCOutlooks: outlookLabels(outlooks),
|
||||
ForecastDiscussion: ForecastDiscussion{
|
||||
KeyMessages: append([]string(nil), discussion.KeyMessages...),
|
||||
ShortTerm: discussion.ShortTerm,
|
||||
},
|
||||
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
|
||||
WeatherStory: weatherStoryLabel(story),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
value, ok, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func locationName(metadata briefing.Metadata) string {
|
||||
if metadata.Location != nil {
|
||||
if metadata.Location.Name != "" && metadata.Location.Region != "" {
|
||||
return metadata.Location.Name + ", " + metadata.Location.Region
|
||||
}
|
||||
if metadata.Location.Name != "" {
|
||||
return metadata.Location.Name
|
||||
}
|
||||
}
|
||||
if metadata.SourceLocation != "" {
|
||||
return metadata.SourceLocation
|
||||
}
|
||||
if metadata.SourceLocationID != "" {
|
||||
return metadata.SourceLocationID
|
||||
}
|
||||
return "Unknown location"
|
||||
}
|
||||
|
||||
func periodLabel(period timeutil.Period, location *time.Location) string {
|
||||
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
|
||||
}
|
||||
|
||||
func timeLabel(value time.Time, location *time.Location) string {
|
||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||
}
|
||||
|
||||
func currentConditionsLabel(current briefing.CurrentConditionsModule) string {
|
||||
parts := []string{}
|
||||
if current.ConditionText != "" {
|
||||
parts = append(parts, current.ConditionText)
|
||||
}
|
||||
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
|
||||
parts = append(parts, temperature)
|
||||
}
|
||||
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
|
||||
parts = append(parts, "feels like "+apparent)
|
||||
}
|
||||
if current.RelativeHumidityPercent != nil {
|
||||
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
|
||||
}
|
||||
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
|
||||
parts = append(parts, wind)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "No current conditions available."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
|
||||
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
|
||||
for _, period := range hourly.Periods {
|
||||
summary := period.TextDescription
|
||||
if summary == "" {
|
||||
summary = period.Name
|
||||
}
|
||||
rows = append(rows, HourlyForecastRow{
|
||||
Time: firstNonEmpty(period.PeriodBegins, period.Name),
|
||||
Summary: summary,
|
||||
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
|
||||
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
|
||||
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
|
||||
parts := []string{}
|
||||
if timing.MaxPopPercent != nil {
|
||||
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
|
||||
if timing.MaxPopTime != "" {
|
||||
max += " at " + timing.MaxPopTime
|
||||
}
|
||||
parts = append(parts, max)
|
||||
}
|
||||
for _, window := range timing.PrecipitationWindows {
|
||||
label := window.PeriodBegins
|
||||
if window.PeriodEnds != "" {
|
||||
label += " to " + window.PeriodEnds
|
||||
}
|
||||
if window.MaxPopPercent != nil {
|
||||
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
|
||||
if window.MaxPopTime != "" {
|
||||
label += " at " + window.MaxPopTime
|
||||
}
|
||||
label += ")"
|
||||
}
|
||||
parts = append(parts, label)
|
||||
}
|
||||
if timing.ThunderMentioned {
|
||||
parts = append(parts, "Thunder is mentioned in the forecast.")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "No precipitation timing signal above threshold."
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func alertLabels(alerts briefing.AlertDigestModule) []string {
|
||||
if alerts.Missing {
|
||||
return []string{"Alert source missing."}
|
||||
}
|
||||
out := make([]string, 0, len(alerts.Relevant))
|
||||
for _, alert := range alerts.Relevant {
|
||||
main := firstNonEmpty(alert.Event, alert.Headline)
|
||||
if main == "" {
|
||||
continue
|
||||
}
|
||||
if alert.Headline != "" && alert.Headline != main {
|
||||
main += ": " + alert.Headline
|
||||
}
|
||||
if alert.Severity != "" {
|
||||
main += " (" + alert.Severity + ")"
|
||||
}
|
||||
out = append(out, main)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
|
||||
out := make([]string, 0, len(outlooks.Outlooks))
|
||||
for _, outlook := range outlooks.Outlooks {
|
||||
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if outlook.PeriodBegins != "" {
|
||||
label += " from " + outlook.PeriodBegins
|
||||
if outlook.PeriodEnds != "" {
|
||||
label += " to " + outlook.PeriodEnds
|
||||
}
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
|
||||
out := make([]string, 0, len(discussion.Discussions))
|
||||
for _, record := range discussion.Discussions {
|
||||
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if record.Summary != "" && record.Summary != label {
|
||||
label += ": " + record.Summary
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
|
||||
if !story.Available {
|
||||
return "No weather story available."
|
||||
}
|
||||
parts := []string{}
|
||||
if story.Title != "" {
|
||||
parts = append(parts, story.Title)
|
||||
}
|
||||
if story.Description != "" {
|
||||
parts = append(parts, story.Description)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Weather story is available."
|
||||
}
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
|
||||
if fahrenheit != nil {
|
||||
return fmt.Sprintf("%d F", rounded(*fahrenheit))
|
||||
}
|
||||
if celsius != nil {
|
||||
return fmt.Sprintf("%d C", rounded(*celsius))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func precipitationLabel(percent *float64) string {
|
||||
if percent == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
|
||||
}
|
||||
|
||||
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
|
||||
speed := ""
|
||||
if mph != nil {
|
||||
speed = fmt.Sprintf("%d mph", rounded(*mph))
|
||||
} else if kmh != nil {
|
||||
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
|
||||
}
|
||||
if direction != "" && speed != "" {
|
||||
speed = direction + " " + speed
|
||||
} else if direction != "" {
|
||||
speed = direction + " wind"
|
||||
}
|
||||
gust := ""
|
||||
if gustMph != nil {
|
||||
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
|
||||
} else if gustKmh != nil {
|
||||
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
|
||||
}
|
||||
switch {
|
||||
case speed != "" && gust != "":
|
||||
return "wind " + speed + ", " + gust
|
||||
case speed != "":
|
||||
return "wind " + speed
|
||||
case gust != "":
|
||||
return "wind " + gust
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func rounded(value float64) int {
|
||||
if value < 0 {
|
||||
return int(value - 0.5)
|
||||
}
|
||||
return int(value + 0.5)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
249
internal/generatedtext/render_context_test.go
Normal file
249
internal/generatedtext/render_context_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestBuildHourlyRenderContext(t *testing.T) {
|
||||
metadata := testMetadata()
|
||||
snapshot := testSnapshot(t)
|
||||
generated := Hourly{
|
||||
Summary: "Storm chances increase through late morning.",
|
||||
Timing: "The main window is 10 AM to noon.",
|
||||
Impacts: "Brief downpours may slow travel.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
}
|
||||
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.ReportTitle != "Hourly Report" {
|
||||
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
|
||||
}
|
||||
if ctx.LocationName != "Brentwood, MO" {
|
||||
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
|
||||
}
|
||||
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
|
||||
}
|
||||
if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
|
||||
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
|
||||
}
|
||||
if len(ctx.HourlyForecast) != 2 {
|
||||
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
|
||||
}
|
||||
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
|
||||
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
|
||||
}
|
||||
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
|
||||
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
|
||||
}
|
||||
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
|
||||
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
|
||||
}
|
||||
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
|
||||
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
|
||||
}
|
||||
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
|
||||
}
|
||||
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
|
||||
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
|
||||
}
|
||||
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
|
||||
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
|
||||
}
|
||||
|
||||
rendered, err := reporttemplate.Render("hourly", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Hourly Report",
|
||||
"Storm chances increase through late morning.",
|
||||
"- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph",
|
||||
"- Flood Watch: Flood Watch until early afternoon (Moderate)",
|
||||
"Morning storms - Morning storms remain the main story.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||
Summary: "Storm chances increase.",
|
||||
Timing: "Late morning.",
|
||||
Impacts: "Brief downpours.",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata() briefing.Metadata {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
return briefing.Metadata{
|
||||
RunID: "run-1",
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: generatedAt,
|
||||
End: generatedAt.Add(6 * time.Hour),
|
||||
},
|
||||
Location: &briefing.LocationContext{
|
||||
Name: "Brentwood",
|
||||
Region: "MO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(t *testing.T) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: string(module.CurrentConditions),
|
||||
Value: briefing.CurrentConditionsModule{
|
||||
ConditionText: "Partly cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ApparentTemperatureF: floatPtr(76),
|
||||
RelativeHumidityPercent: floatPtr(71),
|
||||
WindSpeedMph: floatPtr(8),
|
||||
WindDirection: "S",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: string(module.HourlyForecast),
|
||||
Value: briefing.HourlyForecastModule{
|
||||
Periods: []briefing.HourlyForecastPeriod{
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 9:00 AM",
|
||||
TextDescription: "Cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||
WindSpeedMph: floatPtr(8),
|
||||
WindDirection: "S",
|
||||
},
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
TextDescription: "Showers",
|
||||
TemperatureF: floatPtr(75),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(70),
|
||||
WindSpeedMph: floatPtr(10),
|
||||
WindGustMph: floatPtr(18),
|
||||
WindDirection: "S",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: string(module.PrecipTiming),
|
||||
Value: briefing.PrecipTimingModule{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
ProbabilityThreshold: 50,
|
||||
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 12:00 PM",
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
},
|
||||
},
|
||||
ThunderMentioned: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: string(module.AlertDigest),
|
||||
Value: briefing.AlertDigestModule{
|
||||
Checked: true,
|
||||
ActiveCount: 1,
|
||||
RelevantCount: 1,
|
||||
Relevant: []briefing.AlertSummary{
|
||||
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
Value: briefing.SPCConvectiveOutlooksModule{
|
||||
Checked: true,
|
||||
OutlookCount: 1,
|
||||
Outlooks: []briefing.SPCConvectiveOutlookRecord{
|
||||
{
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
PeriodBegins: "2026-05-29 at 7:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 3:00 PM",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: string(module.AreaForecastDiscussion),
|
||||
Value: briefing.AreaForecastDiscussionModule{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
Value: briefing.SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: "categorical severity_rank >= 3",
|
||||
Discussions: []briefing.SPCConvectiveDiscussionRecord{
|
||||
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: string(module.WeatherStory),
|
||||
Value: briefing.WeatherStoryModule{
|
||||
Available: true,
|
||||
Title: "Morning storms",
|
||||
Description: "Morning storms remain the main story.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user