Add Today generated text assets

This commit is contained in:
2026-06-15 14:45:49 +00:00
parent 4e704e4f51
commit 8ff5c44324
14 changed files with 1023 additions and 22 deletions

View File

@@ -12,9 +12,11 @@ import (
const (
schemaIDHourly = "hourly"
schemaIDToday = "today"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDToday = "today"
templateIDTomorrow = "tomorrow"
)
@@ -44,6 +46,12 @@ var catalog = []catalogEntry{
validate: validateHourly,
renderContextBuilder: buildHourlyContext,
},
{
schemaID: schemaIDToday,
templateID: templateIDToday,
validate: validateToday,
renderContextBuilder: buildTodayContext,
},
{
schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow,
@@ -134,6 +142,10 @@ func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateToday(data []byte) (any, []byte, error) {
return ValidateToday(data)
}
func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data)
}
@@ -146,6 +158,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
}
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
today, ok := generated.(Today)
if !ok {
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
}
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
}
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow)
if !ok {

View File

@@ -93,6 +93,32 @@ func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
}
}
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
definition := report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
t.Fatalf("handler IDs = %q/%q, want today/today", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Today template source", template)
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
@@ -129,6 +155,29 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
today, normalized, err := todayHandler.Validate([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(today) error = %v", err)
}
if _, ok := today.(Today); !ok {
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
@@ -161,4 +210,24 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires today generated text`) {
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
}
}

View File

@@ -52,6 +52,46 @@ type TomorrowRenderContext struct {
Derived facts.DerivedFacts
}
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TodayTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TodayDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TodayPlanning *briefing.TodayPlanningModule
}
type TodayDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type TomorrowReportContext struct {
Title string
ForecastDate time.Time
@@ -116,6 +156,40 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}, nil
}
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required")
}
modules, err := todayTemplateModules(snapshot, derived)
if err != nil {
return TodayRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
return TodayRenderContext{
Report: TodayReportContext{
Title: "Today's Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDate.Format("Monday"),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
@@ -151,6 +225,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil
}
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TodayTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TodayTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TodayTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TodayTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TodayTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TodayTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TodayTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TodayTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return TodayTemplateModules{}, err
}
planning, err := lookup.todayPlanning()
if err != nil {
return TodayTemplateModules{}, err
}
return TodayTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
TodayPlanning: planning,
}, nil
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
@@ -329,6 +470,10 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
}
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
}
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
}
@@ -346,11 +491,34 @@ func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error
return &value, nil
}
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TodayDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
type orderedDaypartRow struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
if dayparts == nil || len(*dayparts) == 0 {
return nil
}
out := make([]TomorrowDaypartContext, 0, len(*dayparts))
out := make([]orderedDaypartRow, 0, len(*dayparts))
seen := map[string]struct{}{}
for _, daypart := range ordered {
for _, key := range daypartModuleKeyCandidates(daypart) {
@@ -362,7 +530,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
continue
}
seen[key] = struct{}{}
out = append(out, TomorrowDaypartContext{Key: key, Summary: value})
out = append(out, orderedDaypartRow{Key: key, Summary: value})
break
}
}
@@ -375,7 +543,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
}
sort.Strings(remaining)
for _, key := range remaining {
out = append(out, TomorrowDaypartContext{Key: key, Summary: (*dayparts)[key]})
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
}
return out
}

View File

@@ -136,6 +136,130 @@ func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
}
}
func TestBuildTodayRenderContext(t *testing.T) {
metadata := testTodayMetadata()
snapshot := testTodaySnapshot(t)
generated := Today{
Summary: "Today starts quiet, then showers become more likely later in the day.",
ForecastDiscussion: []string{
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
},
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
}
collected := testCollected()
derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
if ctx.Report.Title != "Today's Weather" {
t.Fatalf("Report.Title = %q, want Today's Weather", ctx.Report.Title)
}
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
}
if ctx.Report.GeneratedAtLabel != "Monday, June 15, 2026 at 7:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Today {
t.Fatalf("Modules.Metadata = %#v, want today metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
}
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
}
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
}
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
}
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
}
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TodayPlanning == nil {
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
}
if ctx.Modules.TodayPlanning.MorningReadiness[0] != "Take sunglasses early." {
t.Fatalf("Modules.TodayPlanning = %#v, want today planning facts", ctx.Modules.TodayPlanning)
}
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("today", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Today's Weather",
"**Forecast date:** Monday, June 15, 2026",
"Today starts quiet, then showers become more likely later in the day.",
"Currently, it is 58°F and clear.",
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.",
"## Planning Notes",
"- Take sunglasses early.",
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Today's Weather",
"## Current Conditions",
"## Daypart Forecast",
"- **Morning:**",
"- **Afternoon:**",
"## Precipitation Timing",
"## Planning Notes",
"## Forecast Discussion",
})
}
func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{
Summary: "Dry weather is expected today.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 || ctx.Modules.TodayPlanning != nil {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
}
rendered, err := reporttemplate.Render("today", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, unwanted := range []string{"## Current Conditions", "## Precipitation Timing", "## Planning Notes"} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template included %q without module data:\n%s", unwanted, text)
}
}
if !strings.Contains(text, "- No daypart forecast details are available.") {
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
}
}
func TestBuildTomorrowRenderContext(t *testing.T) {
metadata := testTomorrowMetadata()
snapshot := testTomorrowSnapshot(t)
@@ -290,6 +414,26 @@ func testTomorrowMetadata() briefing.Metadata {
}
}
func testTodayMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-today",
ReportID: report.Today,
PromptID: "weather.today_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testCollected() facts.CollectedFacts {
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
}
@@ -299,6 +443,14 @@ func testDerived() facts.DerivedFacts {
}
func testTomorrowDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
func testTodayDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
func testCivilDayDerived() facts.DerivedFacts {
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
return facts.DerivedFacts{
@@ -321,6 +473,134 @@ func testTomorrowDerived() facts.DerivedFacts {
}
}
func testTodaySnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.Metadata,
StanzaName: string(module.Metadata),
Value: briefing.MetadataModule{
RunID: "run-today",
ReportID: report.Today,
PromptID: "weather.today_generated_text",
GeneratedAt: testTodayMetadata().GeneratedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testTodayMetadata().ValidPeriod,
},
},
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionTextLower: "clear",
TemperatureF: intPtr(58),
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
},
},
},
{
ID: module.DerivedDailySummary,
StanzaName: string(module.DerivedDailySummary),
Value: briefing.DerivedDailySummaryModule{
Date: "Monday, June 15, 2026",
HighTempF: intPtr(74),
LowTempF: intPtr(61),
DailyPrecipitationProbability: intPtr(70),
},
},
{
ID: module.DerivedDaypartSummaries,
StanzaName: string(module.DerivedDaypartSummaries),
Value: map[string]briefing.DerivedDaypartSummaryModule{
"afternoon": {
DisplayName: "Afternoon",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "mid 70s",
DominantConditionDisplay: "Showers",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
"morning": {
DisplayName: "Morning",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "low 60s",
DominantConditionDisplay: "Partly cloudy",
DominantConditionLower: "partly cloudy",
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
},
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Rain chances increase late."},
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "convective outlook",
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Rain returns",
},
},
{
ID: module.TodayPlanning,
StanzaName: string(module.TodayPlanning),
Value: briefing.TodayPlanningModule{
MorningReadiness: []string{"Take sunglasses early."},
OutdoorPlanning: []string{"Best outdoor window: Morning (quiet weather)."},
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func testSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{

View File

@@ -0,0 +1,37 @@
package generatedtext
import (
"fmt"
"strings"
)
type Today struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateToday(data []byte) (Today, []byte, error) {
value, err := decodeGeneratedText[Today](data, "today")
if err != nil {
return Today{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Today{}, nil, fmt.Errorf("today generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "today")
if err != nil {
return Today{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateTodayNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateToday([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [
" A front will keep rain chances elevated. ",
"",
" Temperatures stay mild through the afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
if value.Summary != "Showers are likely today." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateToday([]byte(`{
"summary": "Showers are likely today.",
"forecast_discussion": ["A front will keep rain chances elevated."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode today generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are likely today."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateToday([]byte(test.in))
if err == nil {
t.Fatal("ValidateToday() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,38 @@
TASK: You are writing structured prose slots for a current-day weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on today's valid period in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for today.
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to today.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
Lead with the most practical local outcome for today. If an active warning is relevant during the report period, lead with the hazard.
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
# forecast_discussion
Use deterministic module facts and narrative products to explain the most useful details for the current day.
Useful context may include:
- timing of condition changes by daypart or hour
- boundaries, forcing, moisture, instability, or storm mode when supported
- active alerts or SPC outlooks that apply to the location
- planning concerns surfaced by `today_planning`
- confidence or uncertainty
# precipitation_timing
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.

View File

@@ -13,11 +13,13 @@ var assets embed.FS
var templates = map[string]string{
"hourly": "templates/hourly.md.tmpl",
"today": "templates/today.md.tmpl",
"tomorrow": "templates/tomorrow.md.tmpl",
}
var schemas = map[string]string{
"hourly": "schemas/hourly.generated_text.schema.json",
"today": "schemas/today.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
}

View File

@@ -30,6 +30,18 @@ func TestTomorrowTemplateLookup(t *testing.T) {
}
}
func TestTodayTemplateLookup(t *testing.T) {
source, err := Template("today")
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", "## Daypart Forecast", "## Planning Notes", "## Forecast Discussion"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
}
}
func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly")
if err != nil {
@@ -69,6 +81,37 @@ func TestTomorrowSchemaLookup(t *testing.T) {
}
}
func TestTodaySchemaLookup(t *testing.T) {
data, err := Schema("today")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
schema := assertSchema(t, data, "summary,forecast_discussion")
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
if !ok {
t.Fatal("schema property forecast_discussion missing or invalid")
}
if property["type"] != "array" {
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
}
if property["minItems"] != float64(1) {
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
}
items, ok := property["items"].(map[string]any)
if !ok || items["type"] != "string" {
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
}
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
t.Helper()
schema := assertSchema(t, data, required)
@@ -305,6 +348,99 @@ func TestRenderTomorrow(t *testing.T) {
})
}
func TestRenderToday(t *testing.T) {
rendered, err := Render("today", testTodayRenderContext{
Report: testTodayReportContext{
Title: "Today's Weather",
ForecastDateLabel: "Monday, June 15, 2026",
GeneratedAtLabel: "Monday, June 15, 2026 at 7:14 AM",
},
GeneratedText: testTomorrowGeneratedText{
Summary: "Today starts dry before showers return later in the day.",
ForecastDiscussion: []string{
"Clouds increase after sunrise.",
"Rain chances peak during the afternoon.",
},
PrecipitationTiming: "A few showers may linger into early evening.",
},
Modules: testTodayModules{
CurrentConditions: &testCurrentConditions{
ConditionTextLower: "clear",
TemperatureF: intPtr(58),
},
Dayparts: []testTomorrowDaypart{
{
Key: "morning",
Summary: testDaypartSummary{
DisplayName: "Morning",
TemperatureTrend: "rising",
TemperatureStartPhraseF: "upper 50s",
TemperatureEndPhraseF: "upper 60s",
DominantConditionDisplay: "Sunny",
DominantConditionLower: "sunny",
},
},
{
Key: "afternoon",
Summary: testDaypartSummary{
DisplayName: "Afternoon",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "upper 70s",
DominantConditionDisplay: "Showers",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
},
},
PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
},
},
TodayPlanning: &testTodayPlanning{
MorningReadiness: []string{"Morning weather looks routine."},
LateDayChangeWatch: []string{"Watch late-day shower timing."},
},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Today's Weather",
"**Forecast date:** Monday, June 15, 2026",
"**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.",
"Currently, it is 58°F and clear.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.",
"## Planning Notes",
"- Morning weather looks routine.",
"- Watch late-day shower timing.",
"Clouds increase after sunrise.",
"Rain chances peak during the afternoon.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Today's Weather",
"## Current Conditions",
"## Daypart Forecast",
"- **Morning:**",
"- **Afternoon:**",
"## Precipitation Timing",
"## Planning Notes",
"## Forecast Discussion",
})
}
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{
@@ -415,6 +551,12 @@ type testTomorrowRenderContext struct {
Modules testTomorrowModules
}
type testTodayRenderContext struct {
Report testTodayReportContext
GeneratedText testTomorrowGeneratedText
Modules testTodayModules
}
type testReportContext struct {
Title string
LocationName string
@@ -435,6 +577,12 @@ type testTomorrowReportContext struct {
GeneratedAtLabel string
}
type testTodayReportContext struct {
Title string
ForecastDateLabel string
GeneratedAtLabel string
}
type testTomorrowGeneratedText struct {
Summary string
ForecastDiscussion []string
@@ -458,6 +606,20 @@ type testTomorrowModules struct {
PrecipTiming *testPrecipTiming
}
type testTodayModules struct {
CurrentConditions *testCurrentConditions
Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming
TodayPlanning *testTodayPlanning
}
type testTodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OutdoorPlanning []string
LateDayChangeWatch []string
}
type testTomorrowDaypart struct {
Key string
Summary testDaypartSummary

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.today.generated_text.schema.json",
"title": "Today GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,33 @@
# {{ .Report.Title }}
**Forecast date:** {{ .Report.ForecastDateLabel }}
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
{{ with .Modules.CurrentConditions }}{{ $current := . }}## Current Conditions
Currently, {{ with .TemperatureF }}it is {{ . }}°F{{ with $current.ConditionTextLower }} and {{ . }}{{ end }}{{ else }}{{ with .ConditionTextLower }}conditions are {{ . }}{{ else }}latest current conditions are available{{ end }}{{ end }}.
{{ end }}## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}{{ with .Modules.TodayPlanning }}{{ if or .MorningReadiness .CommuteSchoolWorkdayConcerns .OutdoorPlanning .LateDayChangeWatch }}
## Planning Notes
{{ with .MorningReadiness }}{{ range . }}- {{ . }}
{{ end }}{{ end }}{{ with .CommuteSchoolWorkdayConcerns }}{{ range . }}- {{ . }}
{{ end }}{{ end }}{{ with .OutdoorPlanning }}{{ range . }}- {{ . }}
{{ end }}{{ end }}{{ with .LateDayChangeWatch }}{{ range . }}- {{ . }}
{{ end }}{{ end }}{{ end }}{{ end }}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ end }}