Add Today generated text assets
This commit is contained in:
@@ -9,22 +9,26 @@ This document describes structured generated-text handling in
|
|||||||
generated-text-template reports and builds curated render contexts for
|
generated-text-template reports and builds curated render contexts for
|
||||||
templates. It also owns the generated-text catalog that connects report
|
templates. It also owns the generated-text catalog that connects report
|
||||||
definitions to validators, render-context builders, schema assets, and template
|
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 And Outputs
|
||||||
|
|
||||||
Inputs:
|
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`
|
- report metadata from `internal/briefing`
|
||||||
- a module snapshot from `internal/module`
|
- a module snapshot from `internal/module`
|
||||||
- validated generated text
|
- validated generated text
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Today` generated text
|
||||||
- typed `Tomorrow` generated text
|
- typed `Tomorrow` generated text
|
||||||
- typed `Hourly` generated text
|
- typed `Hourly` generated text
|
||||||
- normalized stable JSON for validated generated text
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `TodayRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
- generated-text catalog handlers for report definitions that use
|
- 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
|
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||||
JSON when blank.
|
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:
|
The Tomorrow generated text JSON accepts:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -79,7 +95,8 @@ JSON when blank.
|
|||||||
- Malformed generated-text JSON fails with decode context.
|
- Malformed generated-text JSON fails with decode context.
|
||||||
- Unknown generated-text JSON fields fail during decoding.
|
- Unknown generated-text JSON fields fail during decoding.
|
||||||
- Empty required fields fail after trimming whitespace.
|
- 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.
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
- Invalid render metadata, including missing timezone, missing generated time,
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
or invalid valid period, fails before template rendering.
|
or invalid valid period, fails before template rendering.
|
||||||
@@ -91,6 +108,7 @@ JSON when blank.
|
|||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/generatedtext/hourly_test.go`
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/today_test.go`
|
||||||
- `internal/generatedtext/tomorrow_test.go`
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
- `internal/generatedtext/catalog_test.go`
|
- `internal/generatedtext/catalog_test.go`
|
||||||
- `internal/generatedtext/render_context_test.go`
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ in `internal/reporttemplate`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/reporttemplate` owns repository-native report templates and companion
|
`internal/reporttemplate` owns repository-native report templates and companion
|
||||||
GeneratedText JSON schemas. The implemented template contracts are Tomorrow
|
GeneratedText JSON schemas. The implemented template contracts are Today,
|
||||||
Report and Hourly Report.
|
Tomorrow Report, and Hourly Report. Today assets are available internally until
|
||||||
|
a report definition selects them.
|
||||||
|
|
||||||
The package embeds assets from:
|
The package embeds assets from:
|
||||||
|
|
||||||
- `internal/reporttemplate/templates/*.md.tmpl`
|
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||||
- `internal/reporttemplate/schemas/*.schema.json`
|
- `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 And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
@@ -27,9 +32,9 @@ Outputs:
|
|||||||
- GeneratedText schema bytes for prompt/schema configuration
|
- GeneratedText schema bytes for prompt/schema configuration
|
||||||
- rendered Markdown bytes for app orchestration to persist
|
- rendered Markdown bytes for app orchestration to persist
|
||||||
|
|
||||||
The implemented template IDs are `tomorrow` and `hourly`. The implemented
|
The implemented template IDs are `today`, `tomorrow`, and `hourly`. The
|
||||||
schema IDs are also `tomorrow` and `hourly`, backed by
|
implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by
|
||||||
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
matching `*.generated_text.schema.json` files.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ render-context builder, and embedded assets.
|
|||||||
|
|
||||||
## Template Contracts
|
## 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
|
- report metadata labels such as title, location, valid period, and generation
|
||||||
time
|
time
|
||||||
@@ -54,8 +59,8 @@ Tomorrow and Hourly rendering use typed render contexts with:
|
|||||||
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
||||||
forecast discussion, SPC discussion, and weather story
|
forecast discussion, SPC discussion, and weather story
|
||||||
|
|
||||||
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
Today and Tomorrow additionally expose forecast-date labels, ordered daypart
|
||||||
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
|
forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph
|
||||||
forecast discussion generated-text slot. The ordered daypart slice is built in
|
forecast discussion generated-text slot. The ordered daypart slice is built in
|
||||||
Go so templates do not range over maps.
|
Go so templates do not range over maps.
|
||||||
|
|
||||||
@@ -70,10 +75,10 @@ to write for each generated-text prompt. Hourly requires:
|
|||||||
- `summary`
|
- `summary`
|
||||||
- `forecast_discussion`
|
- `forecast_discussion`
|
||||||
|
|
||||||
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
Today and Tomorrow require `summary` and a nonempty `forecast_discussion`
|
||||||
schemas allow optional `precipitation_timing` and `confidence`, and reject
|
array. All generated-text schemas allow optional `precipitation_timing` and
|
||||||
additional properties. Weather truth remains in module outputs; GeneratedText
|
`confidence`, and reject additional properties. Weather truth remains in module
|
||||||
is limited to prose slots consumed by the template.
|
outputs; GeneratedText is limited to prose slots consumed by the template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ especially generated-text-template reports.
|
|||||||
|
|
||||||
Templates are Go `text/template` files. The current implemented templates are:
|
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/tomorrow.md.tmpl`
|
||||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
Templates are rendered from structured contexts such as `TomorrowRenderContext`
|
Templates are rendered from structured contexts such as `TodayRenderContext`,
|
||||||
and `HourlyRenderContext`. Weather data collection, derivation, module
|
`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection,
|
||||||
execution, generated text validation, and artifact paths are handled before
|
derivation, module execution, generated text validation, and artifact paths are
|
||||||
template rendering.
|
handled before template rendering.
|
||||||
|
|
||||||
## Editing Rules
|
## Editing Rules
|
||||||
|
|
||||||
@@ -114,6 +115,34 @@ Prefer `.Modules.Dayparts` over ranging through
|
|||||||
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
falls back to sorted keys for any unmatched entries.
|
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
|
||||||
|
|
||||||
`.Modules` exposes typed outputs from the same module pipeline used for the
|
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
schemaIDHourly = "hourly"
|
schemaIDHourly = "hourly"
|
||||||
|
schemaIDToday = "today"
|
||||||
schemaIDTomorrow = "tomorrow"
|
schemaIDTomorrow = "tomorrow"
|
||||||
|
|
||||||
templateIDHourly = "hourly"
|
templateIDHourly = "hourly"
|
||||||
|
templateIDToday = "today"
|
||||||
templateIDTomorrow = "tomorrow"
|
templateIDTomorrow = "tomorrow"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +46,12 @@ var catalog = []catalogEntry{
|
|||||||
validate: validateHourly,
|
validate: validateHourly,
|
||||||
renderContextBuilder: buildHourlyContext,
|
renderContextBuilder: buildHourlyContext,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDToday,
|
||||||
|
templateID: templateIDToday,
|
||||||
|
validate: validateToday,
|
||||||
|
renderContextBuilder: buildTodayContext,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
schemaID: schemaIDTomorrow,
|
schemaID: schemaIDTomorrow,
|
||||||
templateID: templateIDTomorrow,
|
templateID: templateIDTomorrow,
|
||||||
@@ -134,6 +142,10 @@ func validateHourly(data []byte) (any, []byte, error) {
|
|||||||
return ValidateHourly(data)
|
return ValidateHourly(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateToday(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateToday(data)
|
||||||
|
}
|
||||||
|
|
||||||
func validateTomorrow(data []byte) (any, []byte, error) {
|
func validateTomorrow(data []byte) (any, []byte, error) {
|
||||||
return ValidateTomorrow(data)
|
return ValidateTomorrow(data)
|
||||||
}
|
}
|
||||||
@@ -146,6 +158,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
|
|||||||
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
|
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) {
|
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)
|
tomorrow, ok := generated.(Tomorrow)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -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) {
|
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||||
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
if err != nil {
|
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."]`) {
|
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)
|
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) {
|
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||||
@@ -161,4 +210,24 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
||||||
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,46 @@ type TomorrowRenderContext struct {
|
|||||||
Derived facts.DerivedFacts
|
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 {
|
type TomorrowReportContext struct {
|
||||||
Title string
|
Title string
|
||||||
ForecastDate time.Time
|
ForecastDate time.Time
|
||||||
@@ -116,6 +156,40 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
|
|||||||
}, nil
|
}, 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) {
|
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -151,6 +225,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
|
|||||||
}, nil
|
}, 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) {
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
lookup := newModuleSnapshotLookup(snapshot)
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
metadata, err := lookup.metadata()
|
metadata, err := lookup.metadata()
|
||||||
@@ -329,6 +470,10 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
|
|||||||
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
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) {
|
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
|
||||||
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
|
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
|
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 {
|
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 {
|
if dayparts == nil || len(*dayparts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]TomorrowDaypartContext, 0, len(*dayparts))
|
out := make([]orderedDaypartRow, 0, len(*dayparts))
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, daypart := range ordered {
|
for _, daypart := range ordered {
|
||||||
for _, key := range daypartModuleKeyCandidates(daypart) {
|
for _, key := range daypartModuleKeyCandidates(daypart) {
|
||||||
@@ -362,7 +530,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
out = append(out, TomorrowDaypartContext{Key: key, Summary: value})
|
out = append(out, orderedDaypartRow{Key: key, Summary: value})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +543,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
|
|||||||
}
|
}
|
||||||
sort.Strings(remaining)
|
sort.Strings(remaining)
|
||||||
for _, key := range 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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildTomorrowRenderContext(t *testing.T) {
|
||||||
metadata := testTomorrowMetadata()
|
metadata := testTomorrowMetadata()
|
||||||
snapshot := testTomorrowSnapshot(t)
|
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 {
|
func testCollected() facts.CollectedFacts {
|
||||||
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
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 {
|
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)
|
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
|
||||||
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
||||||
return facts.DerivedFacts{
|
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 {
|
func testSnapshot(t *testing.T) module.Snapshot {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
|||||||
37
internal/generatedtext/today.go
Normal file
37
internal/generatedtext/today.go
Normal 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
|
||||||
|
}
|
||||||
111
internal/generatedtext/today_test.go
Normal file
111
internal/generatedtext/today_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/reporttemplate/prompts/today.generated_text.md
Normal file
38
internal/reporttemplate/prompts/today.generated_text.md
Normal 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.
|
||||||
@@ -13,11 +13,13 @@ var assets embed.FS
|
|||||||
|
|
||||||
var templates = map[string]string{
|
var templates = map[string]string{
|
||||||
"hourly": "templates/hourly.md.tmpl",
|
"hourly": "templates/hourly.md.tmpl",
|
||||||
|
"today": "templates/today.md.tmpl",
|
||||||
"tomorrow": "templates/tomorrow.md.tmpl",
|
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
var schemas = map[string]string{
|
var schemas = map[string]string{
|
||||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||||
|
"today": "schemas/today.generated_text.schema.json",
|
||||||
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestSchemaLookup(t *testing.T) {
|
||||||
data, err := Schema("hourly")
|
data, err := Schema("hourly")
|
||||||
if err != nil {
|
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) {
|
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
schema := assertSchema(t, data, required)
|
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) {
|
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
||||||
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||||
Report: testTomorrowReportContext{
|
Report: testTomorrowReportContext{
|
||||||
@@ -415,6 +551,12 @@ type testTomorrowRenderContext struct {
|
|||||||
Modules testTomorrowModules
|
Modules testTomorrowModules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testTodayRenderContext struct {
|
||||||
|
Report testTodayReportContext
|
||||||
|
GeneratedText testTomorrowGeneratedText
|
||||||
|
Modules testTodayModules
|
||||||
|
}
|
||||||
|
|
||||||
type testReportContext struct {
|
type testReportContext struct {
|
||||||
Title string
|
Title string
|
||||||
LocationName string
|
LocationName string
|
||||||
@@ -435,6 +577,12 @@ type testTomorrowReportContext struct {
|
|||||||
GeneratedAtLabel string
|
GeneratedAtLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testTodayReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDateLabel string
|
||||||
|
GeneratedAtLabel string
|
||||||
|
}
|
||||||
|
|
||||||
type testTomorrowGeneratedText struct {
|
type testTomorrowGeneratedText struct {
|
||||||
Summary string
|
Summary string
|
||||||
ForecastDiscussion []string
|
ForecastDiscussion []string
|
||||||
@@ -458,6 +606,20 @@ type testTomorrowModules struct {
|
|||||||
PrecipTiming *testPrecipTiming
|
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 {
|
type testTomorrowDaypart struct {
|
||||||
Key string
|
Key string
|
||||||
Summary testDaypartSummary
|
Summary testDaypartSummary
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/reporttemplate/templates/today.md.tmpl
Normal file
33
internal/reporttemplate/templates/today.md.tmpl
Normal 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 }}
|
||||||
Reference in New Issue
Block a user