Compare commits
5 Commits
26e6f33cde
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 |
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ¤tIsDay,
|
||||||
|
TemperatureF: ¤tTemp,
|
||||||
|
ApparentTemperatureF: ¤tFeelsLike,
|
||||||
|
RelativeHumidityPercent: ¤tHumidity,
|
||||||
|
WindSpeedMph: ¤tWind,
|
||||||
|
}
|
||||||
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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
internal/forecast/testdata/daily_bundle.json
vendored
10
internal/forecast/testdata/daily_bundle.json
vendored
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user