Clean up generated text helpers

This commit is contained in:
2026-06-15 12:41:45 +00:00
parent 4fe0f40cef
commit b4e0aadbef
5 changed files with 160 additions and 66 deletions

View File

@@ -2,10 +2,7 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
@@ -17,20 +14,9 @@ type Hourly struct {
}
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, err := decodeGeneratedText[Hourly](data, "hourly")
if err != nil {
return Hourly{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
@@ -44,9 +30,9 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) {
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)
normalized, err := normalizeGeneratedText(value, "hourly")
if err != nil {
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err)
return Hourly{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,33 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
var value T
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
return value, nil
}
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
}
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
normalized, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
}
return normalized, nil
}

View File

@@ -152,39 +152,40 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
current, err := lookup.currentConditions()
if err != nil {
return HourlyTemplateModules{}, err
}
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
hourly, err := lookup.hourlyForecast()
if err != nil {
return HourlyTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
precip, err := lookup.precipTiming()
if err != nil {
return HourlyTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
alerts, err := lookup.alertDigest()
if err != nil {
return HourlyTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return HourlyTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
story, err := lookup.weatherStory()
if err != nil {
return HourlyTemplateModules{}, err
}
@@ -202,51 +203,52 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
}
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
current, err := lookup.currentConditions()
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
hourly, err := lookup.hourlyForecast()
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
daily, err := lookup.derivedDailySummary()
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
precip, err := lookup.precipTiming()
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
alerts, err := lookup.alertDigest()
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
story, err := lookup.weatherStory()
if err != nil {
return TomorrowTemplateModules{}, err
}
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning))
planning, err := lookup.tomorrowPlanning()
if err != nil {
return TomorrowTemplateModules{}, err
}
@@ -267,14 +269,79 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
}, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
output, ok := snapshot.LookupStanza(name)
type moduleSnapshotLookup struct {
snapshot module.Snapshot
stanzas map[string]module.Output
}
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
stanzas[output.StanzaName] = output
}
return moduleSnapshotLookup{
snapshot: snapshot,
stanzas: stanzas,
}
}
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
}
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
}
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
}
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
}
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
}
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
}
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
}
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
}
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
}
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
}
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
}
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
}
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
name := string(id)
output, ok := lookup.stanzas[name]
if !ok || output.Value == nil {
return nil, nil
}
value, _, err := module.StanzaValue[T](snapshot, name)
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
if err != nil {
return nil, fmt.Errorf("build render context: %w", err)
return nil, fmt.Errorf("build render context module %q: %w", id, err)
}
return &value, nil
}

View File

@@ -109,8 +109,30 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.Modules.HourlyForecast != nil {
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
if ctx.Modules.CurrentConditions == nil {
t.Fatal("Modules.CurrentConditions = nil, want populated module")
}
if ctx.Modules.HourlyForecast != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.AlertDigest != nil {
t.Fatalf("Modules = %#v, want omitted optional modules to remain nil", ctx.Modules)
}
}
func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: "not a current conditions stanza"},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error")
}
if !strings.Contains(err.Error(), string(module.CurrentConditions)) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want module ID", err)
}
}
@@ -212,7 +234,7 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
}
rendered, err := reporttemplate.Render("tomorrow", ctx)

View File

@@ -1,10 +1,7 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
@@ -16,20 +13,9 @@ type Tomorrow struct {
}
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var value Tomorrow
if err := decoder.Decode(&value); err != nil {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
}
} else {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
value, err := decodeGeneratedText[Tomorrow](data, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
@@ -43,9 +29,9 @@ func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)
normalized, err := normalizeGeneratedText(value, "tomorrow")
if err != nil {
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err)
return Tomorrow{}, nil, err
}
return value, normalized, nil
}