3 Commits

24 changed files with 646 additions and 129 deletions

View File

@@ -201,6 +201,7 @@ reports:
options:
sections:
- short_term
- hourly_forecast
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module

View File

@@ -27,8 +27,8 @@ Outputs:
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `alert_digest`,
`area_forecast_discussion`, and `weather_story`
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story`
- `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning`
@@ -36,6 +36,10 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
## Boundaries
- This package selects and shapes already-collected weather facts for prompts.

View File

@@ -32,6 +32,7 @@ The registry recognizes these IDs:
- `metadata`
- `current_conditions`
- `narrative_forecast`
- `hourly_forecast`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `precip_timing`

View File

@@ -24,7 +24,8 @@ Inputs:
Outputs:
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas, Recent Changes, and source warnings
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
@@ -38,14 +39,37 @@ report:
prompt_id: <prompt_id>
briefing:
metadata: {}
current_conditions: {}
narrative_forecast: {}
applicable_risk_products:
alert_digest: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
hourly_forecast: {}
recent_changes:
items: []
```
The `briefing` mapping contains named module stanzas. Stanza order follows the
module snapshot output order.
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
@@ -91,6 +115,7 @@ Inspect:
## Invariants
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic for generated snapshots.
- Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category.
- Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.

View File

@@ -86,3 +86,4 @@ reports:
- long_term
- weather_story
- outdoor_windows
- hourly_forecast

View File

@@ -160,19 +160,32 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "applicable_risk_products:") ||
!strings.Contains(string(data), "derived_summaries:") ||
!strings.Contains(string(data), "narrative_products:") ||
!strings.Contains(string(data), "raw_data:") ||
!strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "narrative_forecast:") ||
!strings.Contains(string(data), "hourly_forecast:") ||
!strings.Contains(string(data), "area_forecast_discussion:") {
t.Fatalf("data package missing expected content:\n%s", string(data))
}
if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
}
currentIndex := strings.Index(string(data), " current_conditions:")
narrativeIndex := strings.Index(string(data), " narrative_forecast:")
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
if currentIndex < 0 || narrativeIndex < 0 || summaryIndex < 0 || !(currentIndex < narrativeIndex && narrativeIndex < summaryIndex) {
t.Fatalf("data package stanza order is wrong, want current_conditions then narrative_forecast then derived_daily_summary:\n%s", string(data))
riskIndex := strings.Index(string(data), " applicable_risk_products:")
derivedIndex := strings.Index(string(data), " derived_summaries:")
narrativeIndex := strings.Index(string(data), " narrative_products:")
rawIndex := strings.Index(string(data), " raw_data:")
alertIndex := strings.Index(string(data), " alert_digest:")
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
storyIndex := strings.Index(string(data), " weather_story:")
currentIndex := strings.Index(string(data), " current_conditions:")
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
}
savedDataPackage, err := promptinput.LoadYAML(data)
if err != nil {
@@ -192,6 +205,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
}
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
}
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
@@ -679,7 +696,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if err != nil {
t.Fatalf("decode daily summary: %v", err)
}
if !ok || dailySummary["date"] != "2026-05-30" {
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
}
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
@@ -1363,7 +1380,8 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
}},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{
"period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)},
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58",
},
}},
@@ -1385,7 +1403,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
date + "_morning": map[string]any{
"period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")},
"date": date,
"period": date + " at 6:00 AM to " + date + " at 10:00 AM",
"temp_range_f": "50-58",
"max_pop_percent": precip,
"max_pop_time": "6 AM",

View File

@@ -25,6 +25,7 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
{id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
{id: module.HourlyForecast, stanza: "hourly_forecast"},
{id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"},
@@ -45,6 +46,57 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
}
}
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
}
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
}
}
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -61,6 +113,9 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
}
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
}
@@ -80,6 +135,9 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
}
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
}
if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
}
@@ -189,11 +247,13 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
ctx := testModuleContext()
ctx.Collected.Current = nil
ctx.Collected.Narrative = nil
ctx.Collected.Hourly = nil
ctx.Derived.ValidPeriodNarrativePeriods = nil
ctx.Derived.ValidPeriodHourlyPeriods = nil
ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = nil
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.AreaForecastDiscussion, module.WeatherStory} {
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err)
@@ -276,6 +336,10 @@ func testModuleContext() ModuleContext {
narrativePop := 60.0
narrativeWind := 12.0
narrativeWindDirection := 45.0
hourlyTempF := 76.0
hourlyPop := 70.0
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
Resolved: resolved,
@@ -309,6 +373,30 @@ func testModuleContext() ModuleContext {
},
},
},
Hourly: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "hourly",
Periods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
{
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
TextDescription: "Tomorrow showers.",
},
},
},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}},
@@ -341,6 +429,18 @@ func testModuleContext() ModuleContext {
}},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
},
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
{
Name: "Today",

View File

@@ -35,7 +35,7 @@ func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, e
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
value := DerivedDailySummaryModule{
Date: summary.Date,
Date: friendlyDateLabel(summary.Date, timezone),
ThunderMentioned: timing.ThunderMentioned,
}
conditions := map[string]struct{}{}
@@ -85,10 +85,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value)
}
value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
if len(value.DominantConditions) == 0 {
value.DominantConditions = sortedSet(conditions)
}
value.DominantConditions = sortedSet(conditions)
value.Hazards = sortedSet(hazards)
return value, nil
}
@@ -144,22 +141,6 @@ func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.T
return maxPop
}
func narrativeConditions(periods []weatherdata.ForecastPeriod) []string {
seen := map[string]struct{}{}
var out []string
for _, period := range periods {
if period.TextDescription == "" {
continue
}
if _, ok := seen[period.TextDescription]; ok {
continue
}
seen[period.TextDescription] = struct{}{}
out = append(out, period.TextDescription)
}
return out
}
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
if maxPop == nil || maxPop.Value <= 0 {
return ""

View File

@@ -11,22 +11,23 @@ import (
)
type DerivedDaypartSummaryModule struct {
Period timeutil.Period `json:"period"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -44,7 +45,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{
Period: daypart.Period,
Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,

View File

@@ -24,6 +24,9 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.Date != "Friday, May 29, 2026" {
t.Fatalf("Date = %q, want friendly local date", value.Date)
}
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
}
@@ -33,8 +36,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
}
if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." {
t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions)
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
}
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
@@ -147,6 +150,9 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
}
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
}
afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
@@ -160,11 +166,14 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err)
}
jsonText := string(data)
for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {

View File

@@ -0,0 +1,123 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type HourlyForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
}
type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
UVIndex *float64 `json:"uv_index,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
}
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
hourly := ctx.Collected.Hourly
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
return nil, nil
}
value := HourlyForecastModule{
Product: hourly.Product,
IssuedAt: hourly.IssuedAt,
UpdatedAt: copyTime(hourly.UpdatedAt),
SourceLocation: hourly.LocationName,
SourceLocationID: hourly.LocationID,
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
DewpointC: copyFloat(period.DewpointC),
DewpointF: copyFloat(period.DewpointF),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
VisibilityMeters: copyFloat(period.VisibilityMeters),
VisibilityMiles: copyFloat(period.VisibilityMiles),
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
UVIndex: copyFloat(period.UVIndex),
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
})
}
return out
}
func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View File

@@ -74,6 +74,47 @@ func periodClockLabel(period timeutil.Period, timezone string) string {
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
}
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
}
func friendlyDateTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
if err != nil {
return date
}
return parsed.Format("Monday, January 2, 2006")
}
func localDateLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format(timeutil.DateLayout)
}
func clockLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {

View File

@@ -154,6 +154,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
return ctx.Collected.Narrative != nil
case module.CollectedHourlyForecast:
return ctx.Collected.Hourly != nil
case module.CollectedAlerts:
return ctx.Collected.Alerts != nil
case module.CollectedDiscussion:
@@ -274,6 +276,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
},
{
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
},
{
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",

View File

@@ -17,23 +17,23 @@ type NarrativeForecastModule struct {
}
type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
}
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -47,7 +47,7 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
UpdatedAt: copyTime(narrative.UpdatedAt),
SourceLocation: narrative.LocationName,
SourceLocationID: narrative.LocationID,
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods),
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
@@ -55,13 +55,13 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
}
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeForecastPeriod {
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, NarrativeForecastPeriod{
Name: period.Name,
StartTime: period.StartTime,
EndTime: period.EndTime,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -96,6 +96,14 @@ func copyBool(value *bool) *bool {
return &copied
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil

View File

@@ -10,7 +10,6 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Thresholds struct {
@@ -84,13 +83,14 @@ type dailySummaryStanza struct {
}
type daypartSummaryStanza struct {
Period timeutil.Period `json:"period"`
TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
}
type precipTimingStanza struct {

View File

@@ -3,10 +3,8 @@ package changes
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
@@ -98,7 +96,7 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
DailyPrecipitationProbability: &precip,
}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
}},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
@@ -114,10 +112,6 @@ func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
return snapshot
}
func period(start string, end string) timeutil.Period {
return timeutil.Period{Start: at(start), End: at(end)}
}
func testThresholds() Thresholds {
return Thresholds{
TemperatureDegrees: 5,
@@ -136,11 +130,3 @@ func countType(changes []Change, changeType string) int {
}
return count
}
func at(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -5,7 +5,6 @@ import (
"sort"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
@@ -113,10 +112,7 @@ func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[strin
}
func daypartDate(daypart daypartSummaryStanza) string {
if !daypart.Period.Start.IsZero() {
return daypart.Period.Start.Format(timeutil.DateLayout)
}
return ""
return daypart.Date
}
func minInt(a *int, b *int) *int {

View File

@@ -31,7 +31,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
t.Helper()
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": {
Period: period(date+"T06:00:00Z", date+"T10:00:00Z"),
Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,

View File

@@ -14,6 +14,7 @@ const (
Metadata ID = "metadata"
CurrentConditions ID = "current_conditions"
NarrativeForecast ID = "narrative_forecast"
HourlyForecast ID = "hourly_forecast"
DerivedDailySummary ID = "derived_daily_summary"
DerivedDaypartSummaries ID = "derived_daypart_summaries"
PrecipTiming ID = "precip_timing"
@@ -106,6 +107,7 @@ type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
@@ -130,6 +132,7 @@ const (
type MetadataOptions struct{}
type CurrentConditionsOptions struct{}
type NarrativeForecastOptions struct{}
type HourlyForecastOptions struct{}
type DerivedDailySummaryOptions struct{}
type DerivedDaypartSummariesOptions struct{}
type PrecipTimingOptions struct{}

View File

@@ -18,6 +18,35 @@ import (
const SchemaVersion = "weatherreporter.data_package.v2"
const (
metadataStanza = "metadata"
categoryApplicableRiskProducts = "applicable_risk_products"
categoryDerivedSummaries = "derived_summaries"
categoryNarrativeProducts = "narrative_products"
categoryRawData = "raw_data"
)
var briefingCategoryOrder = []string{
categoryApplicableRiskProducts,
categoryDerivedSummaries,
categoryNarrativeProducts,
categoryRawData,
}
var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData,
}
type BuildRequest struct {
Metadata Metadata
Modules module.Snapshot
@@ -144,13 +173,24 @@ func Validate(pkg Package) error {
if len(pkg.Briefing.Order) == 0 {
return fmt.Errorf("briefing stanzas are required")
}
seen := map[string]struct{}{}
for _, name := range pkg.Briefing.Order {
if name == "" {
return fmt.Errorf("briefing stanza name is required")
}
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
seen[name] = struct{}{}
if _, ok := pkg.Briefing.Values[name]; !ok {
return fmt.Errorf("briefing stanza %q is missing", name)
}
if name == metadataStanza {
continue
}
if _, ok := briefingStanzaCategories[name]; !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
}
return nil
}
@@ -191,17 +231,40 @@ func LoadYAML(data []byte) (Package, error) {
func (b BriefingStanzas) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
categoryNames := map[string][]string{}
for _, name := range b.Order {
value, ok := b.Values[name]
if !ok {
continue
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
if name == metadataStanza {
if err := appendYAMLMappingValue(node, name, value); err != nil {
return nil, err
}
continue
}
node.Content = append(node.Content, keyNode, valueNode)
category, ok := briefingStanzaCategories[name]
if !ok {
return nil, fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
categoryNames[category] = append(categoryNames[category], name)
}
for _, category := range briefingCategoryOrder {
names := categoryNames[category]
if len(names) == 0 {
continue
}
categoryNode := &yaml.Node{Kind: yaml.MappingNode}
for _, name := range names {
value := b.Values[name]
if err := appendYAMLMappingValue(categoryNode, name, value); err != nil {
return nil, err
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: category},
categoryNode,
)
}
return node, nil
}
@@ -212,14 +275,46 @@ func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
}
values := map[string]any{}
order := make([]string, 0, len(value.Content)/2)
seen := map[string]struct{}{}
seenCategories := map[string]struct{}{}
categoryOrder := map[string][]string{}
for i := 0; i < len(value.Content); i += 2 {
name := value.Content[i].Value
var stanza any
if err := value.Content[i+1].Decode(&stanza); err != nil {
return err
if name == metadataStanza {
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
return err
}
continue
}
order = append(order, name)
values[name] = stanza
if !knownBriefingCategory(name) {
return fmt.Errorf("unknown briefing category %q", name)
}
if _, ok := seenCategories[name]; ok {
return fmt.Errorf("duplicate briefing category %q", name)
}
seenCategories[name] = struct{}{}
categoryNode := value.Content[i+1]
if categoryNode.Kind != yaml.MappingNode {
return fmt.Errorf("briefing category %q must be a mapping", name)
}
var names []string
for j := 0; j < len(categoryNode.Content); j += 2 {
stanzaName := categoryNode.Content[j].Value
category, ok := briefingStanzaCategories[stanzaName]
if !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName)
}
if category != name {
return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name)
}
if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil {
return err
}
}
categoryOrder[name] = names
}
for _, category := range briefingCategoryOrder {
order = append(order, categoryOrder[category]...)
}
b.Order = order
b.Values = values
@@ -250,6 +345,39 @@ func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
return nil
}
func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return fmt.Errorf("marshal briefing stanza %q: %w", name, err)
}
node.Content = append(node.Content, keyNode, valueNode)
return nil
}
func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error {
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
var stanza any
if err := node.Decode(&stanza); err != nil {
return err
}
seen[name] = struct{}{}
*order = append(*order, name)
values[name] = stanza
return nil
}
func knownBriefingCategory(name string) bool {
for _, category := range briefingCategoryOrder {
if name == category {
return true
}
}
return false
}
func yamlNode(value any) (*yaml.Node, error) {
data, err := json.Marshal(value)
if err != nil {

View File

@@ -106,7 +106,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req.Metadata.PromptID = "weather.three_day_outlook"
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
)
pkg, err := Build(req)
@@ -117,12 +117,12 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
}
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
t.Fatal("Briefing.Values[three_day] missing")
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
}
}
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
@@ -141,9 +141,26 @@ func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
}
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") ||
!strings.Contains(string(first), " narrative_products:\n") ||
!strings.Contains(string(first), " raw_data:\n") ||
!strings.Contains(string(first), " current_conditions:\n") ||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
!strings.Contains(string(first), " condition_text: Partly cloudy") {
t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first))
}
for _, pair := range []struct {
before string
after string
}{
{before: " metadata:\n", after: " applicable_risk_products:\n"},
{before: " applicable_risk_products:\n", after: " derived_summaries:\n"},
{before: " derived_summaries:\n", after: " narrative_products:\n"},
{before: " narrative_products:\n", after: " raw_data:\n"},
} {
if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) {
t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first))
}
}
}
@@ -165,8 +182,51 @@ func TestLoadYAMLRoundTrip(t *testing.T) {
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
}
if loaded.Briefing.Order[1] != "current_conditions" {
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder)
}
if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got)
}
}
func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}})
_, err := Build(req)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) {
t.Fatalf("Build() error = %v, want uncategorized stanza error", err)
}
}
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(`
schema_version: weatherreporter.data_package.v2
run_id: 20260529T100000Z_daily_today
report:
id: daily_today
prompt_id: weather.daily_report
generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago
current_local_date: "2026-05-29"
valid_period:
start: 2026-05-29T05:00:00Z
end: 2026-05-30T05:00:00Z
briefing:
metadata:
run_id: 20260529T100000Z_daily_today
raw_data:
alert_digest:
checked: true
recent_changes:
items: []
`)
_, err := LoadYAML(data)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) {
t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err)
}
}
@@ -190,6 +250,8 @@ func validBuildRequest(t *testing.T) BuildRequest {
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}},
),
}
}

View File

@@ -49,13 +49,25 @@ func dailyTodayModules() []module.ConfigItem {
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
)
}
func dailyTomorrowModules() []module.ConfigItem {
items := dailyTodayModules()
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
return items
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
)
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -270,6 +270,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
},
},
{
@@ -286,6 +287,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
},
},
{