Improve handling of alerts when no active alerts are present

This commit is contained in:
2026-05-29 19:34:23 -05:00
parent 26e6f33cde
commit 3e93a97d10
10 changed files with 126 additions and 11 deletions

View File

@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
}
```
`data: null` is treated as a missing source. Missing optional sources follow the
configured missing-source policy. Missing hourly forecast data fails bundle
fetching because hourly periods are required for report generation.
For most sources, `data: null` is treated as a missing source. Missing optional
sources follow the configured missing-source policy. Missing hourly forecast
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
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
- `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
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
- endpoint path

View File

@@ -53,8 +53,9 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for
inspection.
source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
## Skip And Resume Behavior
@@ -69,6 +70,8 @@ data is required and cannot be skipped.
endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional and stub sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
## Tests

View File

@@ -202,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(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 {
return err
}
if raw == nil {
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
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
@@ -301,6 +306,7 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
type queryOptions struct {
precision bool
timezone bool
allowNull bool
}
type envelope struct {
@@ -339,7 +345,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Query: queryMap(reqURL.Query()),
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
return nil, source, nil
}
@@ -351,6 +357,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
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 {
reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint)

View File

@@ -125,6 +125,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) {
tests := []struct {
name string

View File

@@ -81,6 +81,7 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
ForecastSummaryDate: summary.Date,
},
}
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary)
}

View File

@@ -85,7 +85,11 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
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()},
},
}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
@@ -101,6 +105,19 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
if len(pkg.Daily.RelevantAlerts) != 0 {
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)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if strings.Contains(string(data), `"missing"`) {
t.Fatalf("metadata includes missing for checked empty alerts:\n%s", string(data))
}
}
func TestDailyBriefingAlertExclusion(t *testing.T) {

View File

@@ -35,6 +35,7 @@ type Metadata struct {
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type SourceMetadata struct {
@@ -48,6 +49,13 @@ type SourceMetadata struct {
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 {
Resolved report.Resolved
Bundle *forecast.Bundle
@@ -72,6 +80,7 @@ func BuildMetadata(ctx BuildContext) Metadata {
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
}
}
@@ -124,6 +133,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
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 {
switch id {
case report.DailyToday:

View File

@@ -56,10 +56,12 @@ func BuildStorm(ctx BuildContext) (Package, error) {
Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
return Package{
pkg := Package{
Metadata: BuildMetadata(ctx),
Storm: storm,
}, nil
}
setRelevantAlertCount(&pkg.Metadata, len(alerts))
return pkg, nil
}
func stormHeadlines(alerts []forecast.AlertOverlap) []string {

View File

@@ -49,6 +49,7 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
}
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
return pkg, nil
}

View File

@@ -42,6 +42,7 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
}
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)
return pkg, nil
}