diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index cb5ff26..2030427 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -9,22 +9,26 @@ This document describes structured generated-text handling in generated-text-template reports and builds curated render contexts for templates. It also owns the generated-text catalog that connects report definitions to validators, render-context builders, schema assets, and template -assets. The implemented contracts are Tomorrow Report and Hourly Report. +assets. The implemented contracts are Today, Tomorrow Report, and Hourly +Report. Today support is internal until a report definition references the +`today` schema and template IDs. ## Inputs And Outputs Inputs: -- raw GeneratedText JSON for Tomorrow Report or Hourly Report +- raw GeneratedText JSON for Today, Tomorrow Report, or Hourly Report - report metadata from `internal/briefing` - a module snapshot from `internal/module` - validated generated text Outputs: +- typed `Today` generated text - typed `Tomorrow` generated text - typed `Hourly` generated text - normalized stable JSON for validated generated text +- typed `TodayRenderContext` values for `internal/reporttemplate` - typed `TomorrowRenderContext` values for `internal/reporttemplate` - typed `HourlyRenderContext` values for `internal/reporttemplate` - generated-text catalog handlers for report definitions that use @@ -45,6 +49,18 @@ The hourly generated text JSON accepts: `precipitation_timing` and `confidence` are optional and omitted from normalized JSON when blank. +The Today generated text JSON accepts the same public fields and validation +rules as Tomorrow: + +```json +{ + "summary": "string", + "forecast_discussion": ["string"], + "precipitation_timing": "string", + "confidence": "string" +} +``` + The Tomorrow generated text JSON accepts: ```json @@ -79,7 +95,8 @@ JSON when blank. - Malformed generated-text JSON fails with decode context. - Unknown generated-text JSON fields fail during decoding. - Empty required fields fail after trimming whitespace. -- Tomorrow forecast discussion fails when no nonblank paragraphs remain. +- Today and Tomorrow forecast discussion fails when no nonblank paragraphs + remain. - Missing optional render-context stanzas become nil module pointers. - Invalid render metadata, including missing timezone, missing generated time, or invalid valid period, fails before template rendering. @@ -91,6 +108,7 @@ JSON when blank. Inspect: - `internal/generatedtext/hourly_test.go` +- `internal/generatedtext/today_test.go` - `internal/generatedtext/tomorrow_test.go` - `internal/generatedtext/catalog_test.go` - `internal/generatedtext/render_context_test.go` diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index deedb0a..00766ee 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -6,14 +6,19 @@ in `internal/reporttemplate`. ## Purpose `internal/reporttemplate` owns repository-native report templates and companion -GeneratedText JSON schemas. The implemented template contracts are Tomorrow -Report and Hourly Report. +GeneratedText JSON schemas. The implemented template contracts are Today, +Tomorrow Report, and Hourly Report. Today assets are available internally until +a report definition selects them. The package embeds assets from: - `internal/reporttemplate/templates/*.md.tmpl` - `internal/reporttemplate/schemas/*.schema.json` +Generated-text prompt source files live under +`internal/reporttemplate/prompts/`. They are repository assets for prompt +registration, not embedded lookup APIs. + ## Inputs And Outputs Inputs: @@ -27,9 +32,9 @@ Outputs: - GeneratedText schema bytes for prompt/schema configuration - rendered Markdown bytes for app orchestration to persist -The implemented template IDs are `tomorrow` and `hourly`. The implemented -schema IDs are also `tomorrow` and `hourly`, backed by -`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`. +The implemented template IDs are `today`, `tomorrow`, and `hourly`. The +implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by +matching `*.generated_text.schema.json` files. ## Boundaries @@ -45,7 +50,7 @@ render-context builder, and embedded assets. ## Template Contracts -Tomorrow and Hourly rendering use typed render contexts with: +Today, Tomorrow, and Hourly rendering use typed render contexts with: - report metadata labels such as title, location, valid period, and generation time @@ -54,8 +59,8 @@ Tomorrow and Hourly rendering use typed render contexts with: conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks, forecast discussion, SPC discussion, and weather story -Tomorrow additionally exposes forecast-date labels, ordered daypart forecast -rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph +Today and Tomorrow additionally expose forecast-date labels, ordered daypart +forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph forecast discussion generated-text slot. The ordered daypart slice is built in Go so templates do not range over maps. @@ -70,10 +75,10 @@ to write for each generated-text prompt. Hourly requires: - `summary` - `forecast_discussion` -Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both -schemas allow optional `precipitation_timing` and `confidence`, and reject -additional properties. Weather truth remains in module outputs; GeneratedText -is limited to prose slots consumed by the template. +Today and Tomorrow require `summary` and a nonempty `forecast_discussion` +array. All generated-text schemas allow optional `precipitation_timing` and +`confidence`, and reject additional properties. Weather truth remains in module +outputs; GeneratedText is limited to prose slots consumed by the template. ## Failure Behavior diff --git a/docs/templates.md b/docs/templates.md index 2c65c12..cf80341 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -8,13 +8,14 @@ especially generated-text-template reports. Templates are Go `text/template` files. The current implemented templates are: +- `internal/reporttemplate/templates/today.md.tmpl` - `internal/reporttemplate/templates/tomorrow.md.tmpl` - `internal/reporttemplate/templates/hourly.md.tmpl` -Templates are rendered from structured contexts such as `TomorrowRenderContext` -and `HourlyRenderContext`. Weather data collection, derivation, module -execution, generated text validation, and artifact paths are handled before -template rendering. +Templates are rendered from structured contexts such as `TodayRenderContext`, +`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection, +derivation, module execution, generated text validation, and artifact paths are +handled before template rendering. ## Editing Rules @@ -114,6 +115,34 @@ Prefer `.Modules.Dayparts` over ranging through `.Modules.DerivedDaypartSummaries`; it follows configured daypart order and falls back to sorted keys for any unmatched entries. +## Today Context + +The Today template receives the same five top-level values as Tomorrow, using +`TodayReportContext`, `Today`, and `TodayTemplateModules`. + +Today report metadata includes `.Report.Title`, `.Report.ForecastDate`, +`.Report.ForecastDateLabel`, `.Report.ForecastDayName`, +`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and +`.Report.Timezone`. + +Today generated text uses `.GeneratedText.Summary`, +`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and +`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and +should be rendered with `range`. + +Today modules include the Hourly module fields plus: + +| Variable | Type | Description | +| --- | --- | --- | +| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. | +| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. | +| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. | +| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. | + +Prefer `.Modules.Dayparts` over ranging through +`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and +falls back to sorted keys for any unmatched entries. + ## Modules `.Modules` exposes typed outputs from the same module pipeline used for the diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index b48857e..d3f9e45 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -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 { diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index 9278f5a..649d871 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -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) + } } diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index e30b3cc..0e9078e 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -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 } diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 8bf0f8a..ebdd340 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -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{ diff --git a/internal/generatedtext/today.go b/internal/generatedtext/today.go new file mode 100644 index 0000000..5137dda --- /dev/null +++ b/internal/generatedtext/today.go @@ -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 +} diff --git a/internal/generatedtext/today_test.go b/internal/generatedtext/today_test.go new file mode 100644 index 0000000..b2d3701 --- /dev/null +++ b/internal/generatedtext/today_test.go @@ -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) + } + }) + } +} diff --git a/internal/reporttemplate/prompts/today.generated_text.md b/internal/reporttemplate/prompts/today.generated_text.md new file mode 100644 index 0000000..eaffe96 --- /dev/null +++ b/internal/reporttemplate/prompts/today.generated_text.md @@ -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. diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index d8080d7..5761017 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -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", } diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 30864dc..64d10b3 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -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 diff --git a/internal/reporttemplate/schemas/today.generated_text.schema.json b/internal/reporttemplate/schemas/today.generated_text.schema.json new file mode 100644 index 0000000..d7e4944 --- /dev/null +++ b/internal/reporttemplate/schemas/today.generated_text.schema.json @@ -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" + } + } +} diff --git a/internal/reporttemplate/templates/today.md.tmpl b/internal/reporttemplate/templates/today.md.tmpl new file mode 100644 index 0000000..68f57f4 --- /dev/null +++ b/internal/reporttemplate/templates/today.md.tmpl @@ -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 }}