Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63749a9572 | |||
| 9ff90d33fc |
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-release-assets
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version="$CI_COMMIT_TAG"
|
||||||
|
dist="dist"
|
||||||
|
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
|
||||||
|
|
||||||
|
rm -rf "$dist"
|
||||||
|
mkdir -p "$dist"
|
||||||
|
|
||||||
|
build_binary() {
|
||||||
|
goos="$1"
|
||||||
|
goarch="$2"
|
||||||
|
suffix="$3"
|
||||||
|
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
|
||||||
|
|
||||||
|
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||||
|
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
|
||||||
|
-o "$output" "$pkg"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_binary linux amd64 ""
|
||||||
|
build_binary linux arm64 ""
|
||||||
|
build_binary darwin amd64 ""
|
||||||
|
build_binary darwin arm64 ""
|
||||||
|
build_binary windows amd64 ".exe"
|
||||||
|
build_binary windows arm64 ".exe"
|
||||||
|
|
||||||
|
- name: publish-release
|
||||||
|
image: woodpeckerci/plugin-release
|
||||||
|
depends_on:
|
||||||
|
- build-release-assets
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: GITEA_RELEASE_TOKEN
|
||||||
|
files:
|
||||||
|
- dist/weatherreporter-*
|
||||||
|
checksum: sha256
|
||||||
|
checksum-file: SHA256SUMS
|
||||||
|
checksum-flatten: true
|
||||||
|
file-exists: skip
|
||||||
|
overwrite: false
|
||||||
|
prerelease: false
|
||||||
@@ -53,7 +53,8 @@ The adapter sends these query parameters:
|
|||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||||
discussion requests
|
discussion requests
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`.
|
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||||
|
`format=json`.
|
||||||
|
|
||||||
## Endpoints Used
|
## Endpoints Used
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ The adapter fetches these endpoints once per bundle:
|
|||||||
- `/forecast/narrative`
|
- `/forecast/narrative`
|
||||||
- `/alerts/active`
|
- `/alerts/active`
|
||||||
- `/discussion`
|
- `/discussion`
|
||||||
|
- `/weatherstories/latest`
|
||||||
|
|
||||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||||
subsection endpoints. Report-period selection and daypart summarization happen
|
subsection endpoints. Report-period selection and daypart summarization happen
|
||||||
@@ -86,10 +88,11 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `narrative` for `/forecast/narrative`
|
- `narrative` for `/forecast/narrative`
|
||||||
- `alerts` for `/alerts/active`
|
- `alerts` for `/alerts/active`
|
||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
|
||||||
The adapter also creates missing stub source records for `daily` and
|
The adapter also creates a missing stub source record for `daily` because that
|
||||||
`weather_story` because those source slots exist in the internal bundle but are
|
source slot exists in the internal bundle but is not fetched from the Weather
|
||||||
not fetched from the Weather API.
|
API.
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
@@ -126,6 +129,8 @@ types in `internal/forecast/bundle.go`, including:
|
|||||||
- 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 section text
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
|
- latest weather story title, description, timing, priority, order, alt text,
|
||||||
|
and download URL
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ Outputs:
|
|||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
object for Daily, 3-Day, Weekend, or Storm Report
|
||||||
- optional `currentConditions` prompt context from normalized
|
- optional `currentConditions` prompt context from normalized
|
||||||
`/conditions/current` data when available
|
`/conditions/current` data when available
|
||||||
|
- optional structured `weatherStory` context on report-specific briefing
|
||||||
|
objects when `/weatherstories/latest` is available
|
||||||
- optional JSON file written by `briefing.Save`
|
- optional JSON file written by `briefing.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ 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
|
briefing content, Recent Changes, and source warnings. Briefing content
|
||||||
includes configured location context, current conditions when available,
|
includes configured location context, current conditions when available,
|
||||||
discussion key messages, and short/long-term AFD narratives when the Weather
|
structured weather story context when available, discussion key messages, and
|
||||||
API provides them.
|
short/long-term AFD narratives when the Weather API provides them.
|
||||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||||
`YYYY-MM-DD` in the effective report timezone.
|
`YYYY-MM-DD` in the effective report timezone.
|
||||||
- optional JSON file written by `promptinput.Save`
|
- optional JSON file written by `promptinput.Save`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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. Briefing builders expose normalized current
|
derivation and briefing builders. Briefing builders expose normalized current
|
||||||
conditions as prompt context when `/conditions/current` is available.
|
conditions and weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, source records, and source
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
warnings
|
records, and source warnings
|
||||||
- stub source records for daily forecast and weather story source slots
|
- stub source record for the daily forecast source slot
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ evaluation remains deferred.
|
|||||||
Proposed direction:
|
Proposed direction:
|
||||||
|
|
||||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||||
weather story context when available, hourly thresholds, and material
|
weather story context, hourly thresholds, and material forecast changes;
|
||||||
forecast changes;
|
|
||||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||||
3. persist storm lifecycle state;
|
3. persist storm lifecycle state;
|
||||||
4. generate or update Storm Reports only when a meaningful event is present;
|
4. generate or update Storm Reports only when a meaningful event is present;
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
|||||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +246,27 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
|
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||||
|
}
|
||||||
|
var story forecast.WeatherStory
|
||||||
|
if err := decodeSource(raw, &story); err != nil {
|
||||||
|
return b.handleMalformed(&source, err, false)
|
||||||
|
}
|
||||||
|
if !story.StartTime.IsZero() {
|
||||||
|
source.IssuedAt = &story.StartTime
|
||||||
|
}
|
||||||
|
source.UpdatedAt = story.UpdatedAt
|
||||||
|
b.bundle.WeatherStory = &story
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||||
source := forecast.Source{
|
source := forecast.Source{
|
||||||
Name: sourceName,
|
Name: sourceName,
|
||||||
@@ -307,6 +328,7 @@ type queryOptions struct {
|
|||||||
precision bool
|
precision bool
|
||||||
timezone bool
|
timezone bool
|
||||||
allowNull bool
|
allowNull bool
|
||||||
|
omitUnits bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -366,7 +388,9 @@ func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
|||||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||||
query := reqURL.Query()
|
query := reqURL.Query()
|
||||||
query.Set("format", c.format)
|
query.Set("format", c.format)
|
||||||
query.Set("units", c.units)
|
if !opts.omitUnits {
|
||||||
|
query.Set("units", c.units)
|
||||||
|
}
|
||||||
if opts.precision {
|
if opts.precision {
|
||||||
query.Set("precision", strconv.Itoa(c.precision))
|
query.Set("precision", strconv.Itoa(c.precision))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
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)
|
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||||
}
|
}
|
||||||
|
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory.UpdatedAt == nil {
|
||||||
|
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||||
|
}
|
||||||
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))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 2 {
|
if len(bundle.Warnings) != 1 {
|
||||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
@@ -61,6 +67,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
||||||
}
|
}
|
||||||
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
|
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -74,8 +83,17 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rawURL := range requested {
|
for _, rawURL := range requested {
|
||||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "format=json") {
|
||||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
t.Fatalf("request %q missing format=json", rawURL)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rawURL, "/weatherstories/") {
|
||||||
|
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
|
||||||
|
t.Fatalf("weather story request %q should use format only", rawURL)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
@@ -99,6 +117,16 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if observation.DataSHA256 != want {
|
if observation.DataSHA256 != want {
|
||||||
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
||||||
}
|
}
|
||||||
|
story := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if story.Endpoint != "/weatherstories/latest" {
|
||||||
|
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
|
||||||
|
}
|
||||||
|
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
|
||||||
|
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
|
||||||
|
}
|
||||||
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
|
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
@@ -169,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
|||||||
wantWarns int
|
wantWarns int
|
||||||
wantSource bool
|
wantSource bool
|
||||||
}{
|
}{
|
||||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
||||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
}
|
}
|
||||||
@@ -230,6 +258,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory != nil {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("weather_story source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||||
|
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextCancellation(t *testing.T) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -296,12 +363,13 @@ type handlerOverride struct {
|
|||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fixtures := map[string]string{
|
fixtures := map[string]string{
|
||||||
"/observations": "observations.json",
|
"/observations": "observations.json",
|
||||||
"/conditions/current": "current.json",
|
"/conditions/current": "current.json",
|
||||||
"/forecast/hourly": "hourly.json",
|
"/forecast/hourly": "hourly.json",
|
||||||
"/forecast/narrative": "narrative.json",
|
"/forecast/narrative": "narrative.json",
|
||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
|
"/weatherstories/latest": "weather_story.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"officeId": "LSX",
|
||||||
|
"startTime": "2026-05-30T08:46:00Z",
|
||||||
|
"endTime": "2026-05-31T11:00:00Z",
|
||||||
|
"updatedAt": "2026-05-30T09:00:34Z",
|
||||||
|
"title": "Several Chances for Rain Through Monday",
|
||||||
|
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
|
||||||
|
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
|
||||||
|
"priority": false,
|
||||||
|
"order": 1,
|
||||||
|
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
_, _ = 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":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
_, _ = 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."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"product": "hourly"`) {
|
if !strings.Contains(string(data), `"product": "hourly"`) {
|
||||||
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
|
||||||
|
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||||
@@ -216,6 +221,9 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
||||||
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
||||||
}
|
}
|
||||||
|
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
||||||
|
}
|
||||||
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
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))
|
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||||
}
|
}
|
||||||
@@ -906,6 +914,8 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
_, _ = 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."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
_, _ = 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."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -57,8 +58,17 @@ type DiscussionContext struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WeatherStoryContext struct {
|
type WeatherStoryContext struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Summary string `json:"summary,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||||
@@ -258,10 +268,31 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||||
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
|
if bundle == nil || bundle.WeatherStory == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
|
story := bundle.WeatherStory
|
||||||
|
return &WeatherStoryContext{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTime(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||||
|
|||||||
@@ -162,8 +162,12 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
|||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
|
if bundle.WeatherStory != nil {
|
||||||
items = appendUnique(items, "Weather story source is available.")
|
if bundle.WeatherStory.Title != "" {
|
||||||
|
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
||||||
|
} else {
|
||||||
|
items = appendUnique(items, "Weather story source is available.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, warning := range bundle.Warnings {
|
for _, warning := range bundle.Warnings {
|
||||||
if warning.Code != "" {
|
if warning.Code != "" {
|
||||||
|
|||||||
@@ -50,8 +50,16 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||||
},
|
},
|
||||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
WeatherStory: &forecast.WeatherStory{
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
OfficeID: "LSX",
|
||||||
|
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
||||||
|
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
||||||
|
Title: "Storm Risk",
|
||||||
|
Description: "Strong storms are possible.",
|
||||||
|
AltText: "Weather story graphic showing storm risk.",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||||
}
|
}
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||||
@@ -80,6 +88,9 @@ 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.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
||||||
|
}
|
||||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
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)
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -813,6 +813,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
_, _ = 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":["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."]}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,14 @@ type DiscussionSection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WeatherStory struct {
|
type WeatherStory struct {
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
StartTime time.Time `json:"startTime"`
|
||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
EndTime time.Time `json:"endTime"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user