5 Commits

31 changed files with 577 additions and 78 deletions

View File

@@ -51,6 +51,19 @@ config test suite.
Timezone values may be IANA names, configured aliases such as `Chicago` and Timezone values may be IANA names, configured aliases such as `Chicago` and
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`. `Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
### `location`
`location` is descriptive prompt context included in briefing metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations.
- `id`: short local identifier. Default: `home`.
- `name`: human-readable location name. Default: `Brentwood`.
- `region`: broader forecast area context. Default: `St. Louis Metro`.
The prompt-facing location object also includes `timezone`, derived from the
effective `weather_api.timezone` after CLI overrides such as `--tz`.
### `missing_source` ### `missing_source`
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`. - `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.

View File

@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
} }
``` ```
`data: null` is treated as a missing source. Missing optional sources follow the For most sources, `data: null` is treated as a missing source. Missing optional
configured missing-source policy. Missing hourly forecast data fails bundle sources follow the configured missing-source policy. Missing hourly forecast
fetching because hourly periods are required for report generation. data fails bundle fetching because hourly periods are required for report
generation.
`/alerts/active` is the exception: a successful response with `data: null`
means the endpoint was checked and there are no current active alerts. The
adapter records a non-missing alerts source and an empty alert run.
Malformed JSON envelopes, non-2xx statuses, and response read failures include Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when endpoint context in returned errors. Decode errors include source context when
@@ -92,9 +97,14 @@ Policy behavior:
- `warn`: omit the source data, add a warning, and continue - `warn`: omit the source data, add a warning, and continue
- `none`: omit the source data and continue without a warning - `none`: omit the source data and continue without a warning
For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a
missing-source condition.
## Source Identity ## Source Identity
For non-null source payloads, the adapter records: For source payloads accepted into the bundle, including the explicit `null`
alerts payload, the adapter records:
- source name - source name
- endpoint path - endpoint path
@@ -115,7 +125,7 @@ types in `internal/forecast/bundle.go`, including:
- current condition values - current condition values
- forecast run metadata and `periods` - forecast run metadata and `periods`
- active alert run data - active alert run data
- discussion metadata, key messages, and short/long-term sections - discussion metadata, key messages, and short/long-term section text
The adapter intentionally keeps upstream transport and envelope details inside The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized `internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -16,12 +16,14 @@ Inputs:
- resolved report definition, generation time, timezone, and valid period - resolved report definition, generation time, timezone, and valid period
- forecast bundle with source provenance and warnings - forecast bundle with source provenance and warnings
- derived daily or period summaries where required - derived daily or period summaries where required
- configured units and timezone - configured units, timezone, and descriptive location context
Outputs: Outputs:
- `briefing.Package` with common metadata and one report-specific content - `briefing.Package` with common metadata and one report-specific content
object for Daily, 3-Day, Weekend, or Storm Report object for Daily, 3-Day, Weekend, or Storm Report
- optional `currentConditions` prompt context from normalized
`/conditions/current` data when available
- optional JSON file written by `briefing.Save` - optional JSON file written by `briefing.Save`
## Boundaries ## Boundaries
@@ -34,6 +36,10 @@ Outputs:
The package receives configured units and timezone from the app layer. Daypart The package receives configured units and timezone from the app layer. Daypart
configuration is consumed by `internal/forecast` before briefing builders run. configuration is consumed by `internal/forecast` before briefing builders run.
Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
Current conditions are copied from the normalized `/conditions/current` bundle
source only; observation station and timestamp fields remain provenance.
## External Adapters Used ## External Adapters Used
@@ -74,6 +80,6 @@ Inspect:
- Briefings contain structured weather facts and source context. - Briefings contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source - Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, and source warnings. provenance, source hashes, source warnings, and configured prompt location.
- LLM prompt input packaging and Scriptorium execution remain outside this - LLM prompt input packaging and Scriptorium execution remain outside this
boundary. boundary.

View File

@@ -17,7 +17,12 @@ Inputs:
Outputs: Outputs:
- `promptinput.Package` containing schema version, RunID, report metadata, - `promptinput.Package` containing schema version, RunID, report metadata,
briefing content, Recent Changes, and source warnings briefing content, Recent Changes, and source warnings. Briefing content
includes configured location context, current conditions when available,
discussion key messages, and short/long-term AFD narratives when the Weather
API provides them.
- report metadata includes `currentLocalDate`, the generation date formatted as
`YYYY-MM-DD` in the effective report timezone.
- optional JSON file written by `promptinput.Save` - optional JSON file written by `promptinput.Save`
## Boundaries ## Boundaries
@@ -28,8 +33,8 @@ Outputs:
## Config Fields Used ## Config Fields Used
None directly. Config-derived values are already present in briefing metadata None directly. Config-derived values, including timezone and prompt location
before this package runs. context, are already present in briefing metadata before this package runs.
## External Adapters Used ## External Adapters Used

View File

@@ -66,8 +66,8 @@ valid-period start date for JSON artifacts, and the RunID.
``` ```
Metadata is stored beside briefing snapshots and links the briefing, data Metadata is stored beside briefing snapshots and links the briefing, data
package, preflight, and report paths. Report listing walks metadata files under package, preflight, report paths, and configured prompt location. Report
the snapshots directory. listing walks metadata files under the snapshots directory.
## Prior Lookup ## Prior Lookup

View File

@@ -6,7 +6,8 @@ This document describes Weather API ingestion into `forecast.Bundle`.
`internal/adapters/weatherapi` fetches normalized weather data from one `internal/adapters/weatherapi` fetches normalized weather data from one
configured Weather API endpoint and assembles the bundle consumed by forecast configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and briefing builders. derivation and briefing builders. Briefing builders expose normalized current
conditions as prompt context when `/conditions/current` is available.
## Inputs And Outputs ## Inputs And Outputs
@@ -53,8 +54,9 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for source warnings. Successful `data: null` responses from `/alerts/active`
inspection. represent a checked empty active-alert list, not a missing source.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -69,6 +71,8 @@ data is required and cannot be skipped.
endpoint context. endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch. - Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional and stub sources follow missing-source policy. - Optional and stub sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
## Tests ## Tests

View File

@@ -6,6 +6,11 @@ weather_api:
timezone: "America/Chicago" timezone: "America/Chicago"
format: json format: json
location:
id: home
name: Brentwood
region: St. Louis Metro
missing_source: missing_source:
default: warn default: warn
sources: sources:

View File

@@ -202,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{}) raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
if err != nil { if err != nil {
return err return err
} }
if raw == nil { if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false) return b.handleMissing(&source, "active alerts data is missing", false)
} }
if isJSONNull(raw) {
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.addSource(source)
return nil
}
var alerts forecast.AlertRun var alerts forecast.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
@@ -301,6 +306,7 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
type queryOptions struct { type queryOptions struct {
precision bool precision bool
timezone bool timezone bool
allowNull bool
} }
type envelope struct { type envelope struct {
@@ -339,7 +345,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Query: queryMap(reqURL.Query()), Query: queryMap(reqURL.Query()),
FetchedAt: c.now(), FetchedAt: c.now(),
} }
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) { if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true source.Missing = true
return nil, source, nil return nil, source, nil
} }
@@ -351,6 +357,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil return env.Data, source, nil
} }
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL { func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL := *c.baseURL reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint) reqURL.Path = path.Join(c.baseURL.Path, endpoint)

View File

@@ -43,6 +43,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 { if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion) t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
} }
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
}
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
}
if len(bundle.Sources) != 8 { if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
} }
@@ -125,6 +131,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
} }
} }
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Alerts == nil {
t.Fatal("Alerts = nil, want checked empty alert run")
}
if len(bundle.Alerts.Alerts) != 0 {
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
}
source := sourceByName(t, bundle.Sources, "alerts")
if source.Missing {
t.Fatalf("alerts source Missing = true, want false")
}
if source.DataSHA256 == "" {
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
}
for _, warning := range bundle.Warnings {
if warning.Source == "alerts" {
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) { func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -9,8 +9,12 @@
"Warmer temperatures this weekend." "Warmer temperatures this weekend."
], ],
"shortTerm": { "shortTerm": {
"title": "Short Term", "qualifier": "(Through This Evening)",
"narrative": "A weak boundary may trigger isolated showers." "text": "A weak boundary may trigger isolated showers."
},
"longTerm": {
"qualifier": "(This Weekend)",
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
} }
} }
} }

View File

@@ -522,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summary) }, summary)
case report.ThreeDay, report.Weekend: case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
@@ -534,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries) }, summaries)
} }
return briefing.BuildThreeDay(briefing.BuildContext{ return briefing.BuildThreeDay(briefing.BuildContext{
@@ -541,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries) }, summaries)
case report.Storm: case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{ return briefing.BuildStorm(briefing.BuildContext{
@@ -548,12 +551,26 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}) })
default: default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID) return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
} }
} }
func briefingLocation(cfg config.Config) *briefing.LocationContext {
location := briefing.LocationContext{
ID: cfg.Location.ID,
Name: cfg.Location.Name,
Region: cfg.Location.Region,
Timezone: cfg.WeatherAPI.Timezone,
}
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
return nil
}
return &location
}
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace) return state.NewFilesystemStore(cfg.Workspace)
} }

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -33,7 +34,7 @@ func TestFetchAndSaveBundle(t *testing.T) {
case "/alerts/active": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -195,6 +196,29 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
var savedDataPackage struct {
Report struct {
CurrentLocalDate string `json:"currentLocalDate"`
} `json:"report"`
Briefing briefing.Package `json:"briefing"`
}
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
t.Fatalf("decode data package: %v", err)
}
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
}
location := savedDataPackage.Briefing.Metadata.Location
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
t.Fatalf("data package location = %#v, want configured prompt location", location)
}
current := savedDataPackage.Briefing.CurrentConditions
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
t.Fatalf("data package current conditions = %#v, want current conditions", current)
}
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
}
preflight, err := os.ReadFile(result.PreflightPath) preflight, err := os.ReadFile(result.PreflightPath)
if err != nil { if err != nil {
t.Fatalf("read preflight: %v", err) t.Fatalf("read preflight: %v", err)
@@ -873,7 +897,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
case "/observations": case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current": case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
case "/forecast/hourly": case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
case "/forecast/narrative": case "/forecast/narrative":
@@ -881,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
case "/alerts/active": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@@ -68,19 +68,18 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
if summary == nil { if summary == nil {
return Package{}, fmt.Errorf("daily forecast summary is required") return Package{}, fmt.Errorf("daily forecast summary is required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Daily = &Daily{
Daily: &Daily{ BottomLine: buildBottomLine(summary),
BottomLine: buildBottomLine(summary), Dayparts: summary.Dayparts,
Dayparts: summary.Dayparts, RelevantAlerts: summary.AlertOverlaps,
RelevantAlerts: summary.AlertOverlaps, OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
OutdoorWindows: buildOutdoorWindows(summary.Dayparts), NarrativePeriods: summary.NarrativePeriods,
NarrativePeriods: summary.NarrativePeriods, Discussion: buildDiscussion(summary.Discussion),
Discussion: buildDiscussion(summary.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle), ForecastSummaryDate: summary.Date,
ForecastSummaryDate: summary.Date,
},
} }
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
if ctx.Resolved.Definition.ID == report.DailyTomorrow { if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary) pkg.Daily.Planning = buildTomorrowPlanning(summary)
} }
@@ -250,10 +249,10 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
KeyMessages: discussion.KeyMessages, KeyMessages: discussion.KeyMessages,
} }
if discussion.ShortTerm != nil { if discussion.ShortTerm != nil {
ctx.ShortTerm = discussion.ShortTerm.Narrative ctx.ShortTerm = discussion.ShortTerm.Text
} }
if discussion.LongTerm != nil { if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Narrative ctx.LongTerm = discussion.LongTerm.Text
} }
return ctx return ctx
} }

View File

@@ -15,6 +15,19 @@ import (
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
bundle := loadBundleFixture(t) bundle := loadBundleFixture(t)
currentIsDay := true
currentTemp := 75.9
currentFeelsLike := 76.1
currentHumidity := 56.0
currentWind := 10.7
bundle.Current = &forecast.Current{
ConditionText: "Partly cloudy",
IsDay: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
bundle.Sources[0].DataSHA256 = "abc123" bundle.Sources[0].DataSHA256 = "abc123"
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
location := mustLocation(t) location := mustLocation(t)
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
Bundle: bundle, Bundle: bundle,
Units: "us", Units: "us",
Timezone: "America/Chicago", Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}, summary) }, summary)
if err != nil { if err != nil {
t.Fatalf("BuildDaily() error = %v", err) t.Fatalf("BuildDaily() error = %v", err)
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
} }
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
}
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
}
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
} }
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
if len(pkg.Daily.Discussion.KeyMessages) != 1 { if len(pkg.Daily.Discussion.KeyMessages) != 1 {
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages)) t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
} }
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
}
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
}
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil { if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows) t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
} }
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}}, }},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, Alerts: &forecast.AlertRun{},
Sources: []forecast.Source{
{Name: "hourly", FetchedAt: time.Now()},
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
},
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
} }
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil { if err != nil {
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." { if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary) t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
} }
if pkg.CurrentConditions != nil {
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
}
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
}
if len(pkg.Daily.RelevantAlerts) != 0 { if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
} }
if pkg.Metadata.Alerts == nil {
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
}
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
}
data, err := json.Marshal(pkg.Metadata.Alerts)
if err != nil {
t.Fatalf("marshal alert metadata: %v", err)
}
if strings.Contains(string(data), `"missing"`) {
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
}
} }
func TestDailyBriefingAlertExclusion(t *testing.T) { func TestDailyBriefingAlertExclusion(t *testing.T) {

View File

@@ -14,11 +14,12 @@ import (
const SchemaVersion = "weatherreporter.briefing.v1" const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct { type Package struct {
Metadata Metadata `json:"metadata"` Metadata Metadata `json:"metadata"`
Daily *Daily `json:"daily,omitempty"` CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"` Daily *Daily `json:"daily,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"` ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Storm *Storm `json:"storm,omitempty"` Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
} }
type Metadata struct { type Metadata struct {
@@ -31,10 +32,34 @@ type Metadata struct {
Units string `json:"units"` Units string `json:"units"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Region string `json:"region,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type CurrentConditionsContext struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
} }
type SourceMetadata struct { type SourceMetadata struct {
@@ -48,11 +73,19 @@ type SourceMetadata struct {
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
} }
type AlertStatus struct {
Checked bool `json:"checked"`
ActiveCount int `json:"activeCount"`
RelevantCount int `json:"relevantCount"`
Missing bool `json:"missing,omitempty"`
}
type BuildContext struct { type BuildContext struct {
Resolved report.Resolved Resolved report.Resolved
Bundle *forecast.Bundle Bundle *forecast.Bundle
Units string Units string
Timezone string Timezone string
Location *LocationContext
} }
func BuildMetadata(ctx BuildContext) Metadata { func BuildMetadata(ctx BuildContext) Metadata {
@@ -68,13 +101,82 @@ func BuildMetadata(ctx BuildContext) Metadata {
Units: ctx.Units, Units: ctx.Units,
Timezone: ctx.Timezone, Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceLocationID: sourceLocationID, SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation, SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle), Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle), SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
} }
} }
func buildPackage(ctx BuildContext) Package {
return Package{
Metadata: BuildMetadata(ctx),
CurrentConditions: currentConditions(ctx.Bundle),
}
}
func copyLocation(location *LocationContext) *LocationContext {
if location == nil {
return nil
}
copied := *location
return &copied
}
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil {
return nil
}
current := bundle.Current
context := CurrentConditionsContext{
ConditionText: current.ConditionText,
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC),
DewpointF: copyFloat(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
}
if context.ConditionText == "" &&
context.IsDay == nil &&
context.TemperatureC == nil &&
context.TemperatureF == nil &&
context.ApparentTemperatureC == nil &&
context.ApparentTemperatureF == nil &&
context.DewpointC == nil &&
context.DewpointF == nil &&
context.RelativeHumidityPercent == nil &&
context.WindSpeedKmh == nil &&
context.WindSpeedMph == nil &&
context.WindDirectionDegrees == nil {
return nil
}
return &context
}
func copyBool(value *bool) *bool {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil
}
copied := *value
return &copied
}
func Save(path string, pkg Package) error { func Save(path string, pkg Package) error {
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save briefing package: %w", err) return fmt.Errorf("save briefing package: %w", err)
@@ -124,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
return bundle.Warnings return bundle.Warnings
} }
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
status := &AlertStatus{}
if bundle.Alerts != nil {
status.Checked = true
status.ActiveCount = len(bundle.Alerts.Alerts)
}
for _, source := range bundle.Sources {
if source.Name == "alerts" && source.Missing {
status.Missing = true
break
}
}
if !status.Checked && !status.Missing {
return nil
}
return status
}
func setRelevantAlertCount(metadata *Metadata, count int) {
if metadata.Alerts == nil {
if count == 0 {
return
}
metadata.Alerts = &AlertStatus{}
}
metadata.Alerts.RelevantCount = count
}
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.DailyToday:

View File

@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
Discussion: buildDiscussion(ctx.Bundle.Discussion), Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle), WeatherStory: buildWeatherStory(ctx.Bundle),
} }
return Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Storm = storm
Storm: storm, setRelevantAlertCount(&pkg.Metadata, len(alerts))
}, nil return pkg, nil
} }
func stormHeadlines(alerts []forecast.AlertOverlap) []string { func stormHeadlines(alerts []forecast.AlertOverlap) []string {
@@ -158,7 +158,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
} }
if bundle.Discussion != nil { if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...) items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" { if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.") items = appendUnique(items, "Short-term discussion is available for confidence context.")
} }
} }

View File

@@ -44,7 +44,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{ Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}}, }},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}}, Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Storms may intensify quickly."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
},
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)}, WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
} }
@@ -75,6 +80,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
if pkg.Storm.WeatherStory == nil { if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context") t.Fatal("WeatherStory = nil, want available story context")
} }
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
}
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
}
if len(pkg.Storm.WhatToWatchNext) == 0 { if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs") t.Fatal("WhatToWatchNext length = 0, want watch inputs")
} }

View File

@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
if len(summaries) == 0 { if len(summaries) == 0 {
return Package{}, fmt.Errorf("3-day forecast summaries are required") return Package{}, fmt.Errorf("3-day forecast summaries are required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.ThreeDay = &ThreeDay{
ThreeDay: &ThreeDay{ Discussion: buildDiscussion(summaries[0].Discussion),
Discussion: buildDiscussion(summaries[0].Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle),
},
} }
for _, summary := range summaries { for _, summary := range summaries {
day := buildOutlookDay(summary) day := buildOutlookDay(summary)
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day) pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
} }
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days) pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
return pkg, nil return pkg, nil
} }

View File

@@ -43,7 +43,12 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
}, },
}, },
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}}, Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Unsettled stretch."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
},
}, },
{ {
Date: "2026-05-30", Date: "2026-05-30",
@@ -80,4 +85,10 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
if len(pkg.ThreeDay.RelevantAlerts) != 1 { if len(pkg.ThreeDay.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts)) t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
} }
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
}
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
}
} }

View File

@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
if len(summaries) == 0 { if len(summaries) == 0 {
return Package{}, fmt.Errorf("weekend forecast summaries are required") return Package{}, fmt.Errorf("weekend forecast summaries are required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Weekend = &Weekend{
Weekend: &Weekend{ Discussion: buildDiscussion(summaries[0].Discussion),
Discussion: buildDiscussion(summaries[0].Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle),
},
} }
for _, summary := range summaries { for _, summary := range summaries {
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary)) pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
} }
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days) pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle) pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
return pkg, nil return pkg, nil
} }

View File

@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
}, },
}, },
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}}, Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Timing may shift."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
},
}, },
} }
@@ -85,4 +90,10 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 { if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
t.Fatal("UncertaintyInputs length = 0, want discussion context") t.Fatal("UncertaintyInputs length = 0, want discussion context")
} }
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
}
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
}
} }

View File

@@ -437,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
"generate", "daily", "generate", "daily",
"--config", configPath, "--config", configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
"--tz", "UTC",
"--out", outPath, "--out", outPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
@@ -463,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
t.Fatalf("data package output missing expected content:\n%s", string(data)) t.Fatalf("data package output missing expected content:\n%s", string(data))
} }
var decoded struct {
Briefing struct {
Metadata struct {
Location struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Timezone string `json:"timezone"`
} `json:"location"`
} `json:"metadata"`
} `json:"briefing"`
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode data package: %v", err)
}
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
}
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil { if err != nil {
t.Fatalf("glob preflight: %v", err) t.Fatalf("glob preflight: %v", err)

View File

@@ -14,6 +14,7 @@ const (
type Config struct { type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"` WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
MissingSource MissingSourceConfig `yaml:"missing_source"` MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
@@ -30,6 +31,12 @@ type WeatherAPIConfig struct {
Format string `yaml:"format"` Format string `yaml:"format"`
} }
type LocationConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Region string `yaml:"region"`
}
type MissingSourceConfig struct { type MissingSourceConfig struct {
Default MissingSourcePolicy `yaml:"default"` Default MissingSourcePolicy `yaml:"default"`
Sources map[string]MissingSourcePolicy `yaml:"sources"` Sources map[string]MissingSourcePolicy `yaml:"sources"`

View File

@@ -23,6 +23,9 @@ func TestDefaults(t *testing.T) {
if cfg.WeatherAPI.Format != "json" { if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format) t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
} }
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
}
if cfg.MissingSource.Default != MissingSourceWarn { if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
} }
@@ -43,6 +46,9 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone { if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"]) t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
} }
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location)
}
} }
func TestLoadMinimalExampleConfig(t *testing.T) { func TestLoadMinimalExampleConfig(t *testing.T) {
@@ -63,6 +69,9 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
if cfg.Workspace.Root != "workspace" { if cfg.Workspace.Root != "workspace" {
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root) t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
} }
if cfg.Location.Name != "Brentwood" {
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
}
} }
func TestExplicitMissingConfigReturnsError(t *testing.T) { func TestExplicitMissingConfigReturnsError(t *testing.T) {

View File

@@ -13,6 +13,11 @@ func Defaults() Config {
Timezone: "America/Chicago", Timezone: "America/Chicago",
Format: "json", Format: "json",
}, },
Location: LocationConfig{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
},
MissingSource: MissingSourceConfig{ MissingSource: MissingSourceConfig{
Default: MissingSourceWarn, Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{}, Sources: map[string]MissingSourcePolicy{},

View File

@@ -149,8 +149,8 @@ type Discussion struct {
} }
type DiscussionSection struct { type DiscussionSection struct {
Title string `json:"title,omitempty"` Qualifier string `json:"qualifier,omitempty"`
Narrative string `json:"narrative,omitempty"` Text string `json:"text,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"` IssuedAt *time.Time `json:"issuedAt,omitempty"`
} }

View File

@@ -54,7 +54,15 @@
"issuedAt": "2026-05-29T09:25:00-05:00", "issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [ "keyMessages": [
"Storms are most likely during the morning." "Storms are most likely during the morning."
] ],
"shortTerm": {
"qualifier": "(Through This Evening)",
"text": "Morning showers taper as a weak boundary shifts east."
},
"longTerm": {
"qualifier": "(This Weekend)",
"text": "Warmer and more humid conditions return with periodic rain chances."
}
}, },
"sources": [ "sources": [
{ {

View File

@@ -25,12 +25,13 @@ type Package struct {
} }
type Report struct { type Report struct {
ID report.ID `json:"id"` ID report.ID `json:"id"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"` PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` CurrentLocalDate string `json:"currentLocalDate"`
ValidPeriod timeutil.Period `json:"validPeriod"`
} }
type RecentChanges struct { type RecentChanges struct {
@@ -42,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
} }
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) { func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
if err != nil {
return Package{}, err
}
items := make([]changes.Change, len(recentChanges)) items := make([]changes.Change, len(recentChanges))
copy(items, recentChanges) copy(items, recentChanges)
if items == nil { if items == nil {
@@ -51,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
SchemaVersion: SchemaVersion, SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID, RunID: briefingPackage.Metadata.RunID,
Report: Report{ Report: Report{
ID: briefingPackage.Metadata.ReportID, ID: briefingPackage.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: briefingPackage.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID, PromptID: briefingPackage.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt, GeneratedAt: briefingPackage.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone, Timezone: briefingPackage.Metadata.Timezone,
ValidPeriod: briefingPackage.Metadata.ValidPeriod, CurrentLocalDate: localDate,
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
}, },
Briefing: briefingPackage, Briefing: briefingPackage,
RecentChanges: RecentChanges{Items: items}, RecentChanges: RecentChanges{Items: items},
@@ -68,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
return pkg, nil return pkg, nil
} }
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
}
return generatedAt.In(location).Format(timeutil.DateLayout), nil
}
func Validate(pkg Package) error { func Validate(pkg Package) error {
if pkg.SchemaVersion == "" { if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required") return fmt.Errorf("schemaVersion is required")
@@ -87,6 +101,9 @@ func Validate(pkg Package) error {
if pkg.Report.Timezone == "" { if pkg.Report.Timezone == "" {
return fmt.Errorf("report.timezone is required") return fmt.Errorf("report.timezone is required")
} }
if pkg.Report.CurrentLocalDate == "" {
return fmt.Errorf("report.currentLocalDate is required")
}
if !pkg.Report.ValidPeriod.IsValid() { if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid") return fmt.Errorf("report.validPeriod must be valid")
} }

View File

@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
} }
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
}
if pkg.Briefing.Daily == nil { if pkg.Briefing.Daily == nil {
t.Fatal("Briefing.Daily = nil") t.Fatal("Briefing.Daily = nil")
} }
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
}
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
}
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
} }
} }
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
briefingPackage.Metadata.Timezone = "America/Chicago"
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
}
}
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.Timezone = "Not/AZone"
_, err := Build(briefingPackage)
if err == nil {
t.Fatal("Build() error = nil, want invalid timezone error")
}
if !strings.Contains(err.Error(), "report timezone") {
t.Fatalf("error = %q, want report timezone context", err.Error())
}
}
func TestValidateRequiresFields(t *testing.T) { func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBriefingPackage())
if err != nil { if err != nil {
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
} }
} }
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
pkg.Report.CurrentLocalDate = ""
err = Validate(pkg)
if err == nil {
t.Fatal("Validate() error = nil, want required field error")
}
if !strings.Contains(err.Error(), "currentLocalDate") {
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
}
}
func TestBuildThreeDayDataPackage(t *testing.T) { func TestBuildThreeDayDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage() briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day" briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Units: "us", Units: "us",
Timezone: "America/Chicago", Timezone: "America/Chicago",
Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
}, },
}, },
CurrentConditions: &briefing.CurrentConditionsContext{
ConditionText: "Partly cloudy",
},
Daily: &briefing.Daily{ Daily: &briefing.Daily{
ForecastSummaryDate: "2026-05-29", ForecastSummaryDate: "2026-05-29",
}, },

View File

@@ -122,6 +122,9 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if decoded.RenderedReportPath != renderedReportPath { if decoded.RenderedReportPath != renderedReportPath {
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath) t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
} }
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
}
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
} }
@@ -437,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
GeneratedAt: resolved.GeneratedAt, GeneratedAt: resolved.GeneratedAt,
Units: "us", Units: "us",
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod, Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: resolved.Timezone,
},
ValidPeriod: resolved.ValidPeriod,
}, },
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"}, Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
} }

View File

@@ -21,6 +21,7 @@ type Metadata struct {
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
Location *briefing.LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
@@ -43,6 +44,7 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
GeneratedAt: metadata.GeneratedAt, GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone, Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(briefingPackage.Metadata.Location),
SourceLocationID: briefingPackage.Metadata.SourceLocationID, SourceLocationID: briefingPackage.Metadata.SourceLocationID,
SourceLocation: briefingPackage.Metadata.SourceLocation, SourceLocation: briefingPackage.Metadata.SourceLocation,
Sources: briefingPackage.Metadata.Sources, Sources: briefingPackage.Metadata.Sources,
@@ -53,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
RenderedReportPath: paths.RenderedReport, RenderedReportPath: paths.RenderedReport,
} }
} }
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
if location == nil {
return nil
}
copied := *location
return &copied
}