6 Commits

25 changed files with 1167 additions and 84 deletions

View File

@@ -43,7 +43,7 @@ config test suite.
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
- `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`.
- `precision`: numeric precision query value. Default: `0`, which requests integer values where supported.
- `units`: Weather API units query value. Default: `us`.
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
- `format`: Weather API response format. Must be `json`. Default: `json`.

View File

@@ -18,6 +18,17 @@ fail before any HTTP request when the base URL is empty or not absolute.
The HTTP client uses `weather_api.timeout`.
Before fetching bundle sources, the adapter performs a warmup `GET` to
`/conditions/current` with the same query parameters as the current-conditions
source request. This is a temporary connectivity check for VPN wake-up behavior
until the upstream service provides a dedicated health endpoint. A successful
warmup requires a 2xx response whose body can be read; the adapter does not
decode or validate the response envelope during warmup.
Warmup attempts, warmup delay, source-fetch retry attempts, and source-fetch
retry delay are internal adapter defaults. They are not configuration-file
fields or CLI flags yet. `weather_api.timeout` applies to each HTTP attempt.
## Response Envelope
Every response used by the adapter must be JSON with a top-level `data` field:
@@ -45,6 +56,12 @@ Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy.
Source-fetch transport failures and retryable HTTP statuses are retried before
the adapter returns an error. Retryable statuses are `408`, `429`, `500`,
`502`, `503`, and `504`. Non-retryable statuses, malformed JSON envelopes,
missing `data`, `data: null` missing-source outcomes, and source decode errors
are not retried.
## Query Parameters
The adapter sends these query parameters:
@@ -52,7 +69,8 @@ The adapter sends these query parameters:
- `format`: from `weather_api.format`; configuration validation requires `json`
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
conditions, hourly forecast, and narrative forecast requests; the built-in
default is `0`
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
@@ -116,7 +134,10 @@ bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Endpoints Used
The adapter fetches these endpoints once per bundle:
The adapter warms up `/conditions/current` once before bundle fetching begins,
with retries if needed. It then fetches these source endpoints once per bundle,
except when a source request is retried after a transient transport or server
failure:
- `/observations`
- `/conditions/current`

View File

@@ -87,6 +87,8 @@ The app layer passes effective units, timezone, and location context into the
module context. `internal/facts` consumes daypart configuration before module
builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
The `metadata` module carries report context and source warnings only; alert
status and relevant alert details belong in the `alert_digest` module.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to
@@ -96,10 +98,12 @@ subset of discussion fields. Hourly Report defaults this module to
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It also emits a curated `risk_digest`
for categorical outlooks that overlap the report period, contain the location,
and meet the configured-in-code minimum severity for report rendering. It does
not emit GeoJSON geometry, source URL, expiration time, or severity rank.
outlook contains the configured location. It enriches matching outlooks with
embedded background definitions owned by this package. It also emits a curated
`risk_digest` for categorical outlooks that overlap the report period, contain
the location, and meet the configured-in-code minimum severity for report
rendering. It does not emit GeoJSON geometry, source URL, expiration time, or
severity rank.
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,

View File

@@ -229,9 +229,11 @@ validation.
- `risk_digest`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
`background_definition`, `period_begins`, `period_ends`, `issued_at`,
`contains_location`, and `image_url`. `background_definition` is embedded
briefing reference content for known outlook type/label pairs and may include
`plain_language`, `official_description`, and `relative_level`. It omits GeoJSON
geometry, source URL, expiration time, and severity rank.
The optional `risk_digest` list is a curated report-rendering subset of
categorical outlooks that overlap the report period, contain the configured

View File

@@ -81,7 +81,10 @@ Today, Tomorrow, and Hourly call the shared `alert_digest` and
template at render time and receive the same typed render context as the
caller. The `alert_digest` partial renders the combined Alerts and Risk
Products section from relevant NWS alerts and curated SPC outlook digest
records.
records. Rendered NWS alert bullets include alert identity and timing but omit
instruction and description text. Rendered SPC outlook bullets start at
Enhanced Risk; lower-risk SPC entries may still exist in module snapshots and
data packages.
## Schema Contract

304
docs/roadmap/promptkit.md Normal file
View File

@@ -0,0 +1,304 @@
# Promptkit Migration Roadmap
## Purpose
This roadmap defines the scope and desired end state for replacing the
external Scriptorium CLI integration with the Promptkit Go library. The
migration is not yet implemented. Current Scriptorium behavior remains
documented outside `docs/roadmap/` until the replacement is complete.
A separate staged implementation plan will describe how to move from the
current code to this target state. That plan should reference this roadmap
rather than redefine its architectural decisions or scope.
## Desired End State
Weatherreporter uses a pinned released version of
`gitea.maximumdirect.net/eric/promptkit` as its in-process prompt preparation
and LLM execution engine. The `scriptorium` executable, subprocess adapter,
configuration, runtime dependency, and integration documentation have been
removed.
The migration does not change weatherreporter's fundamental product behavior.
Weather selection, forecast derivation, report periods, module construction,
Recent Changes, generated-text interpretation, Markdown templates, durable
state, inspection, output copies, and distributor notification remain owned by
weatherreporter.
All report prompts and private response schemas are versioned application
assets. Operators may configure Promptkit execution profiles without replacing
the report-owned prompt and schema corpus. One Promptkit engine is constructed
per CLI invocation and shared by every report in that invocation, including
all reports in a morning or evening batch.
Promptkit is isolated behind a weatherreporter-owned prompt execution contract.
Promptkit request, result, validation, error, profile, backend, and provider
types do not leak into application orchestration, report definitions, domain
packages, CLI summaries, state contracts, or distributor behavior.
## Goals
- Remove the runtime dependency on the `scriptorium` executable.
- Replace shell-free subprocess orchestration with typed in-process Promptkit
preparation and execution.
- Preserve the seven report definitions and their existing prompt IDs.
- Preserve both direct-Markdown and generated-text-template report workflows.
- Preserve deterministic module snapshots and structured Recent Changes.
- Preserve context cancellation, actionable errors, secret redaction, and
inspectable failures.
- Improve durable prompt provenance with prompt, input, profile, model,
validation, usage, and timing metadata.
- Keep content-rich prompt and response diagnostics separate from routine
metadata and CLI output.
- Keep tests offline and deterministic through injected Promptkit model
clients and fixtures.
## Non-Goals
The migration will not:
- move meteorological selection, derivation, thresholds, or comparison logic
into prompts or Promptkit;
- send raw unbounded Weather API responses to the model;
- replace weatherreporter's generated-text domain validation or Markdown
template rendering;
- add a general workflow engine, provider plugin system, or arbitrary backend
registry to weatherreporter;
- add automatic provider, validation, or capacity retries;
- add concurrent report generation to the existing sequential batch workflow;
- expose Promptkit types as a weatherreporter component contract;
- keep a production-selectable Scriptorium/Promptkit dual-run mode; or
- use an unpublished Promptkit commit, committed Go workspace, or committed
local module replacement.
## Locked Decisions
### Dependency And Versioning
- The initial integration will pin Promptkit `v0.3.0`.
- Coordinated local development may temporarily use the sibling Promptkit
checkout, but committed module metadata must reference the tagged release.
- A future Promptkit upgrade requires an explicit review of the public engine,
prompt/profile/schema formats, error identities, validation behavior, and
outbound provider contract used by weatherreporter.
### Application Boundary
- Promptkit remains an adapter boundary even though it runs in process.
- A weatherreporter-owned contract will represent preparation, execution,
output formats, validation, usage, provenance, and neutral error categories.
- The Promptkit adapter will map public Promptkit values into that contract at
the boundary.
- App orchestration and test fakes will depend on the weatherreporter contract,
not on Promptkit.
- Existing Scriptorium-specific generation mode names will be replaced with
provider-neutral names.
### Prompt And Schema Ownership
- Weatherreporter will embed all report prompt definitions, prompt content,
and private response schemas.
- Prompt assets will remain separate files rather than inline Go strings.
- The current Scriptorium prompt corpus will be retrieved before the
implementation stage that establishes the embedded Promptkit assets.
- The retrieved corpus will be reviewed and converted to the pinned Promptkit
format without changing report intent or prompt IDs.
- The four existing generated-text prompt fragments and schemas under
`internal/reporttemplate` will be reconciled with that corpus rather than
duplicated.
- Direct-Markdown prompt assets for the three-day, weekend, and storm reports
will become weatherreporter-owned assets.
- Weatherreporter needs one centralized embedded prompt/schema source; it does
not need Notarius's multi-module asset-flattening registry.
### Profiles, Backends, And Credentials
- Execution profiles remain operator-configurable rather than embedded report
policy.
- Configuration will support at most one external profile source: a profile
directory or a single profile file.
- Prompt definitions may provide their normal default profile, while
weatherreporter may support an explicit configured profile selection.
- Credential values remain in environment variables or file-backed
environment secrets. Configuration contains only credential source names.
- Provider credentials must not appear in logs, errors, CLI output, durable
metadata, preparation artifacts, execution artifacts, or debug summaries.
- Weatherreporter will not expose Promptkit's general backend registry as
arbitrary application configuration.
### Engine Lifetime
- One Promptkit engine will be constructed per CLI invocation at the
application composition boundary.
- Single-report generation will use that engine for preparation and execution.
- Morning and evening batches will share the same engine across every planned
report.
- Per-report orchestration will not construct its own default Promptkit engine.
- Promptkit backend capacity state and HTTP transport will therefore be shared
consistently for the invocation.
### Prompt Input
- Promptkit will continue to receive the curated `data_package` produced by
`internal/promptinput`.
- Weatherreporter will serialize the data package once, atomically persist
those exact bytes, and supply the same bytes as a Promptkit inline artifact.
- The managed data-package path may be supplied as non-secret artifact
provenance.
- Weatherreporter will not delegate unrestricted path loading to Promptkit's
default file artifact reader.
- The same immutable Promptkit request will be used for preparation and
execution so the preflight and run inputs cannot diverge.
### Preparation And Execution
- Promptkit `Prepare` replaces the current Scriptorium render preflight.
- Promptkit `Run` performs both Markdown and structured generated-text
execution.
- Promptkit basic validation will be used where appropriate for direct
Markdown output.
- Promptkit JSON Schema validation provides the provider-facing and first
structured-output check for generated-text reports.
- Weatherreporter's `internal/generatedtext` validation remains the final
report-specific domain boundary.
- Weatherreporter's `internal/reporttemplate` remains responsible for
generated-text Markdown rendering.
- Weatherreporter will atomically persist Promptkit output rather than asking
the dependency to write managed report files.
- The migration will not rely on Promptkit output repair. Promptkit v0.3.0's
public engine validates in a single pass even when a prompt declares repair
attempts.
## Durable Artifacts And Observability
Routine durable artifacts should retain useful non-secret provenance without
persisting full rendered prompts by default.
The preparation record should contain:
- prompt ID and version;
- prompt definition hash;
- rendered prompt hash;
- input hashes;
- selected profile and backend identity;
- effective model identity;
- output contract summary; and
- preparation timing.
The execution record and run metadata should contain, when available:
- Promptkit run ID;
- prompt ID, version, and hashes;
- input hashes;
- selected profile, backend, and model identity;
- generated-content hash;
- token usage;
- start, end, and duration;
- validation status and bounded diagnostics; and
- the path of any separately persisted raw generated output.
Provider endpoints, full effective model parameter maps, rendered messages,
schema bodies, data-package contents, and generated content do not belong in
routine metadata or CLI summaries.
Rendered messages and other content-rich preparation or response diagnostics
will be available only through an explicitly enabled debug mechanism. Debug
artifacts must be documented as potentially sensitive, must not contain
credentials, and must have a clear operator-owned retention policy.
## Failure Contract
Promptkit returns a completed `RunResult` for output-validation failure but no
partial result for operational preparation or execution errors. Weatherreporter
will preserve that distinction.
- A preparation failure produces a redacted weatherreporter-owned failure
receipt with report, RunID, prompt, stage, timing, and classified error
context. It does not fabricate a Promptkit preparation result.
- An operational execution failure retains the successful preparation record
and adds a redacted execution failure receipt. No partial Promptkit result or
model output is invented.
- A Promptkit validation failure retains the returned result, raw generated
output, validation details, and safe provenance before the report fails.
- A later weatherreporter generated-text decode, domain-validation, or template
failure retains every raw and validated artifact reached before that stage.
- Context cancellation takes precedence when the caller context is canceled.
- Promptkit capacity rejection maps to a weatherreporter-owned error category.
It is an operational report failure, not invalid model output.
- Single-report commands return the classified failure with available
inspectable paths.
- Batch runs continue independent later reports under the existing batch
failure policy.
- The migration adds no automatic retries. Any future retry policy belongs to
app orchestration, not the Promptkit adapter.
## Compatibility Requirements
- Report IDs, prompt IDs, report selection, valid periods, artifact grouping,
output names, and distributor bundle behavior remain stable.
- Module snapshot and Recent Changes behavior remains deterministic.
- Promptkit receives only the existing curated prompt-input boundary.
- Generated reports continue to use the managed Markdown path as the
distributor upload source.
- RunID lookup and inspection remain available for successful and failed runs.
- Existing managed state paths remain stable where their meaning is unchanged.
Scriptorium-specific artifact names or schemas may change when retaining
them would misrepresent the new contract.
- Any artifact or metadata schema change is explicit, documented, and covered
by state and inspection tests.
- Prompt or generated content is not added to routine logs or CLI summaries.
- Tests do not require live Promptkit providers or credentials.
## Verification And Completion Criteria
The migration is complete when:
- all seven reports prepare and execute through Promptkit using embedded
report-owned assets;
- direct-Markdown and generated-text-template paths have deterministic offline
adapter and app-level coverage;
- preparation, provider failure, capacity rejection, cancellation, timeout,
Promptkit validation failure, generated-text validation failure, template
failure, and successful generation preserve their specified artifacts;
- morning and evening batches construct one shared engine and preserve current
collection, planning, ordering, continuation, output, and notification
behavior;
- configuration examples load and contain no Scriptorium fields;
- CLI summaries and inspection commands expose the new artifact contract
without Promptkit dependency types;
- Scriptorium code, configuration, tests, and runtime documentation have been
removed;
- non-roadmap documentation describes only the implemented Promptkit
integration;
- `go test ./...`, CLI help validation, and `git diff --check` pass; and
- no committed `go.work`, local `replace`, live-provider test, or secret-bearing
fixture remains.
Fixture-based comparison with the current Scriptorium behavior is sufficient
for migration verification. A production-selectable dual-run period is not
required because model calls are nondeterministic, costly, and difficult to
compare meaningfully.
## External Prerequisite
Before implementing the embedded asset stage, the current Scriptorium prompt
corpus must be made available in this repository. It should include the seven
prompt definitions, referenced content files, private response schemas,
relevant default-profile declarations, and any shared prompt fragments needed
to reproduce current report behavior.
## Open Questions
- What exact `promptkit.*` configuration fields should replace the current
Scriptorium fields, including the name and precedence of an optional explicit
profile override?
- Should weatherreporter expose Promptkit's conventional `local` backend
registration as a narrow configuration feature, or rely initially on
built-in and endpoint-only profiles?
- Should report definitions store an explicit Promptkit prompt version, or
should each embedded prompt ID be required to have exactly one version?
- What CLI or configuration control enables sensitive prompt/response debug
artifacts, and where should those artifacts live?
- What final names and schema versions should replace the
Scriptorium-specific preflight and run-result artifacts while balancing
semantic clarity with existing state-path compatibility?

View File

@@ -367,6 +367,10 @@ YAML.
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition` | *briefing.SPCOutlookBackgroundDefinition | Embedded background context for known SPC outlook products. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.PlainLanguage` | string | Plain-language outlook definition. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.OfficialDescription` | string | Official or source-aligned outlook definition. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.RelativeLevel` | string | Relative categorical risk level, when defined. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |

View File

@@ -74,7 +74,7 @@ Relevant docs: [CLI reference](cli.md).
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
Likely cause: the configured Weather API endpoint is unreachable, returned a
non-2xx response, or returned an invalid response envelope.
non-2xx response after retries, or returned an invalid response envelope.
Diagnostic:
@@ -83,8 +83,11 @@ weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
service response. The adapter fetches `/observations`, `/conditions/current`,
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
service response. The adapter first warms up `/conditions/current`, then fetches
`/observations`, `/conditions/current`, `/forecast/hourly`,
`/forecast/narrative`, `/alerts/active`, `/discussion`,
`/weatherstories/latest`, and `/outlooks/convective`. Transient VPN wake-up
failures and retryable upstream statuses are retried automatically.
Relevant docs: [Configuration reference](config.md).

View File

@@ -1,7 +1,7 @@
weather_api:
base_url: https://weather.api.example.com/
timeout: 15s
precision: 1
precision: 0
units: us
timezone: "America/Chicago"
format: json

View File

@@ -24,6 +24,12 @@ import (
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second
)
type Client struct {
@@ -35,6 +41,12 @@ type Client struct {
precision int
missingSource config.MissingSourceConfig
now func() time.Time
warmupEndpoint string
warmupAttempts int
warmupDelay time.Duration
fetchAttempts int
fetchRetryDelay time.Duration
}
type Option func(*Client)
@@ -80,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
Default: cfg.MissingSource.Default,
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
now: time.Now,
warmupEndpoint: defaultWarmupEndpoint,
warmupAttempts: defaultWarmupAttempts,
warmupDelay: defaultWarmupDelay,
fetchAttempts: defaultFetchAttempts,
fetchRetryDelay: defaultFetchRetryDelay,
}
for _, opt := range opts {
opt(client)
@@ -89,6 +106,10 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
}
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := c.warmup(ctx); err != nil {
return nil, err
}
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
@@ -397,24 +418,9 @@ type envelope struct {
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
return nil, weatherdata.Source{}, err
}
var env envelope
@@ -440,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) error {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
lastErr = err
} else {
return nil
}
if attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
attempts := positiveAttemptCount(c.fetchAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
}
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
if err == nil {
return reqURL, body, nil
}
lastErr = err
lastRetryable = isRetryableRequestError(err)
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
}
}
if lastRetryable {
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
return nil, nil, lastErr
}
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
err = fmt.Errorf("fetch %s: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err}
}
return reqURL, nil, err
}
return reqURL, body, nil
}
type retryableRequestError struct {
err error
}
func (e retryableRequestError) Error() string {
return e.err.Error()
}
func (e retryableRequestError) Unwrap() error {
return e.err
}
func isRetryableRequestError(err error) bool {
_, ok := err.(retryableRequestError)
return ok
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return ctx.Err()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func positiveAttemptCount(attempts int) int {
if attempts < 1 {
return 1
}
return attempts
}
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}

View File

@@ -80,8 +80,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
if len(requested) != len(wantPaths)+1 {
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
}
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
}
for _, want := range wantPaths {
if !containsPath(requested, want) {
@@ -132,9 +135,15 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
t.Fatalf("request %q missing units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
continue
}
if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") {
if !strings.Contains(rawURL, "precision=0") {
t.Fatalf("request %q missing precision=0", rawURL)
}
}
}
}
@@ -186,7 +195,7 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
func TestHTTPErrorIsActionable(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
@@ -194,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
if err == nil {
t.Fatal("FetchBundle() error = nil, want HTTP error")
}
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
}
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
var requested []string
var warmupCalls int
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
warmupCalls++
if warmupCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("vpn waking up"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Current == nil {
t.Fatal("Current = nil, want successful fetch after warmup retry")
}
if warmupCalls != 3 {
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
}
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
}
}
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup failure")
}
if !strings.Contains(err.Error(), "warm up weather API") ||
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
!strings.Contains(err.Error(), "2 attempts") ||
!strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if hourlyCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Hourly == nil {
t.Fatal("Hourly = nil, want successful fetch after retry")
}
if hourlyCalls != 2 {
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
}
}
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`not-json`))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want envelope decode error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestRequiredHourlyForecast(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
@@ -212,6 +346,9 @@ func TestRequiredHourlyForecast(t *testing.T) {
if !strings.Contains(err.Error(), "hourly forecast data") {
t.Fatalf("error = %q, want hourly context", err.Error())
}
if got := countPath(requested, "/forecast/hourly"); got != 1 {
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
}
}
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
@@ -420,6 +557,43 @@ func TestContextCancellation(t *testing.T) {
}
}
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
var cancel context.CancelFunc
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if cancel != nil {
cancel()
}
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
client.fetchRetryDelay = time.Hour
ctx, cancelFunc := context.WithCancel(context.Background())
cancel = cancelFunc
defer cancelFunc()
start := time.Now()
_, err := client.FetchBundle(ctx)
elapsed := time.Since(start)
if err == nil {
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
}
if !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("error = %q, want context cancellation", err.Error())
}
if elapsed > time.Second {
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
}
}
func TestHTTPTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
@@ -432,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
_, err = client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want timeout error")
}
if !strings.Contains(err.Error(), "/observations") {
if !strings.Contains(err.Error(), defaultWarmupEndpoint) {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
@@ -464,8 +640,9 @@ func TestSaveBundle(t *testing.T) {
}
type handlerOverride struct {
status int
body string
status int
body string
handler http.HandlerFunc
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
@@ -485,6 +662,10 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
*requested = append(*requested, r.URL.String())
}
if override, ok := overrides[r.URL.Path]; ok {
if override.handler != nil {
override.handler(w, r)
return
}
w.WriteHeader(override.status)
_, _ = w.Write([]byte(override.body))
return
@@ -510,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
return client
}
@@ -532,6 +715,16 @@ func containsPath(requested []string, path string) bool {
return false
}
func countPath(requested []string, path string) int {
var count int
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
count++
}
}
return count
}
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
t.Helper()
for _, source := range sources {

View File

@@ -390,8 +390,12 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
}
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
t.Fatal("data package metadata stanza missing")
metadataStanza, ok := savedDataPackage.Briefing.Values["metadata"].(map[string]any)
if !ok {
t.Fatalf("data package metadata stanza = %#v, want metadata map", savedDataPackage.Briefing.Values["metadata"])
}
if _, ok := metadataStanza["alerts"]; ok {
t.Fatalf("data package metadata contains alerts, want alert details only in alert_digest: %#v", metadataStanza)
}
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
@@ -572,6 +576,10 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
" spc_convective_discussion:",
" included_because: categorical severity_rank >= 3",
" label_text: Slight Risk",
"background_definition:",
"plain_language: Scattered severe storms possible.",
"official_description: Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
"relative_level: 2 of 5",
" period_begins:",
" period_ends:",
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
@@ -753,7 +761,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
"# Hourly Report",
"Storm chances increase through late morning.",
"## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM.",
"## Precipitation Timing",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
@@ -762,6 +770,9 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText)
}
}
if strings.Contains(reportText, "Avoid low-water crossings.") {
t.Fatalf("rendered hourly report includes alert instruction:\n%s", reportText)
}
if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
}

View File

@@ -0,0 +1,72 @@
{
"categorical:TSTM": {
"plain_language": "General or non-severe thunderstorms.",
"official_description": "No severe thunderstorms expected.",
"relative_level": "0 of 5"
},
"categorical:MRGL": {
"plain_language": "Isolated severe storms possible.",
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
"relative_level": "1 of 5"
},
"categorical:SLGT": {
"plain_language": "Scattered severe storms possible.",
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
"relative_level": "2 of 5"
},
"categorical:ENH": {
"plain_language": "Numerous severe storms possible.",
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
"relative_level": "3 of 5"
},
"categorical:MDT": {
"plain_language": "Widespread severe storms likely.",
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
"relative_level": "4 of 5"
},
"categorical:HIGH": {
"plain_language": "Major severe outbreak expected.",
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
"relative_level": "5 of 5"
},
"tornado:CIG1": {
"plain_language": "Conditional potential for significant tornadoes.",
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"tornado:CIG2": {
"plain_language": "Conditional potential for strong tornadoes.",
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"tornado:CIG3": {
"plain_language": "Conditional potential for violent tornadoes.",
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"wind:CIG1": {
"plain_language": "Conditional potential for significant severe wind.",
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"wind:CIG2": {
"plain_language": "Conditional potential for intense severe wind.",
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"wind:CIG3": {
"plain_language": "Conditional potential for extreme severe wind.",
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"hail:CIG1": {
"plain_language": "Conditional potential for significant hail.",
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 2"
},
"hail:CIG2": {
"plain_language": "Conditional potential for giant hail.",
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 2"
}
}

View File

@@ -253,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
@@ -264,6 +261,9 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
}
if strings.Contains(jsonText, `"alerts"`) {
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
}
}
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {

View File

@@ -20,7 +20,6 @@ type MetadataModule struct {
ValidPeriod timeutil.Period `json:"valid_period"`
Location *LocationContext `json:"location,omitempty"`
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
Alerts *AlertDigestModule `json:"alerts,omitempty"`
}
type SourceWarningSummary struct {
@@ -44,7 +43,6 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}

View File

@@ -0,0 +1,37 @@
package briefing
import (
"embed"
"encoding/json"
"fmt"
"strings"
)
//go:embed assets/spc_convective_outlook_definitions.json
var spcConvectiveOutlookDefinitionAssets embed.FS
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
if err != nil {
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
}
var definitions map[string]SPCOutlookBackgroundDefinition
if err := json.Unmarshal(data, &definitions); err != nil {
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
}
return definitions
}
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
if !ok {
return nil
}
return &definition
}
func spcOutlookDefinitionKey(outlookType string, label string) string {
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
}

View File

@@ -25,15 +25,22 @@ type SPCConvectiveOutlooksModule struct {
}
type SPCConvectiveOutlookRecord struct {
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
}
type SPCOutlookBackgroundDefinition struct {
PlainLanguage string `json:"plain_language,omitempty"`
OfficialDescription string `json:"official_description,omitempty"`
RelativeLevel string `json:"relative_level,omitempty"`
}
type SPCConvectiveOutlookDigest struct {
@@ -87,15 +94,16 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
continue
}
records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
})
}
return records

View File

@@ -65,6 +65,12 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.BackgroundDefinition == nil ||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
}
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
}
@@ -83,7 +89,7 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} {
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
@@ -95,6 +101,98 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
}
}
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
tests := []struct {
name string
outlookType string
label string
want bool
}{
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
if tt.want && definition == nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
}
if !tt.want && definition != nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
}
})
}
}
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
outlook.Label = "FOO"
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
if value.Outlooks[0].BackgroundDefinition != nil {
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
}
}
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
wantKeys := []string{
"categorical:TSTM",
"categorical:MRGL",
"categorical:SLGT",
"categorical:ENH",
"categorical:MDT",
"categorical:HIGH",
"tornado:CIG1",
"tornado:CIG2",
"tornado:CIG3",
"wind:CIG1",
"wind:CIG2",
"wind:CIG3",
"hail:CIG1",
"hail:CIG2",
}
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
}
for _, key := range wantKeys {
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
}
}
for key, definition := range spcOutlookBackgroundDefinitions {
if strings.TrimSpace(key) == "" {
t.Fatal("embedded SPC outlook background definitions contain empty key")
}
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
}
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
}
if strings.Contains(definition.OfficialDescription, "by themselves") {
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
}
}
}
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)

View File

@@ -28,6 +28,9 @@ func TestDefaults(t *testing.T) {
if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
}
if cfg.WeatherAPI.Precision != 0 {
t.Fatalf("Precision = %d, want 0", cfg.WeatherAPI.Precision)
}
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)
}

View File

@@ -8,7 +8,7 @@ func Defaults() Config {
return Config{
WeatherAPI: WeatherAPIConfig{
Timeout: 10 * time.Second,
Precision: 1,
Precision: 0,
Units: "us",
Timezone: "America/Chicago",
Format: "json",

View File

@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
@@ -99,6 +99,9 @@ func TestBuildHourlyRenderContext(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "Avoid low-water crossings.") {
t.Fatalf("rendered template includes alert instruction:\n%s", text)
}
}
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {

View File

@@ -61,7 +61,7 @@ func Render(id string, data any) ([]byte, error) {
if err != nil {
return nil, err
}
tmpl, err := template.New(id).Option("missingkey=error").Parse(source)
tmpl, err := template.New(id).Funcs(templateFuncs()).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err)
}

View File

@@ -270,8 +270,7 @@ func TestRenderHourly(t *testing.T) {
"Storm chances increase through late morning.",
"Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.",
"## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.",
"- **9:00 AM:** 74°F and cloudy.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.",
@@ -285,6 +284,11 @@ func TestRenderHourly(t *testing.T) {
if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") {
t.Fatalf("rendered template included omitted details:\n%s", text)
}
for _, unwanted := range []string{"Avoid low-water crossings.", "Slight risk for severe thunderstorms"} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template included %q:\n%s", unwanted, text)
}
}
assertOrderedText(t, text, []string{
"# Hourly Report",
"## Alert Digest",
@@ -371,8 +375,7 @@ func TestRenderTomorrow(t *testing.T) {
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.",
"## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -388,6 +391,8 @@ func TestRenderTomorrow(t *testing.T) {
for _, unwanted := range []string{
"upper 50s.\n\n- **Morning:**",
"upper 60s.\n\n- **Afternoon:**",
"Secure outdoor objects.",
"Slight risk for severe thunderstorms",
} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text)
@@ -473,7 +478,7 @@ func TestRenderDaily(t *testing.T) {
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"The selected day starts dry before showers return later in the day.",
"## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.",
"- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM.",
"- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -486,6 +491,9 @@ func TestRenderDaily(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "Monitor creek levels.") {
t.Fatalf("rendered template included alert description:\n%s", text)
}
assertOrderedText(t, text, []string{
"# Monday's Weather",
"The selected day starts dry before showers return later in the day.",
@@ -581,8 +589,7 @@ func TestRenderToday(t *testing.T) {
"**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.",
"## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
"Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -606,7 +613,7 @@ func TestRenderToday(t *testing.T) {
"## Precipitation Timing",
"## Forecast Discussion",
})
for _, unwanted := range []string{"## Planning Notes", "Morning weather looks routine.", "Watch late-day shower timing.", "- **Evening:**", "Forecast details are limited"} {
for _, unwanted := range []string{"## Planning Notes", "Morning weather looks routine.", "Watch late-day shower timing.", "- **Evening:**", "Forecast details are limited", "Secure outdoor objects.", "Slight risk for severe thunderstorms"} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template included %q:\n%s", unwanted, text)
}
@@ -736,6 +743,73 @@ func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
}
}
func TestAlertDigestOmitsBelowThresholdSPCRiskOnlySection(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{Title: "Hourly Report"},
GeneratedText: testGeneratedText{
Summary: "Storm chances remain low.",
ForecastDiscussion: "Only isolated severe storms are possible.",
},
Modules: testModules{
CurrentConditions: &testCurrentConditions{},
HourlyForecast: &testHourlyForecast{},
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, unwanted := range []string{"## Alert Digest", "SPC Convective Outlook", "Slight risk for severe thunderstorms"} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template included %q for below-threshold SPC-only digest:\n%s", unwanted, text)
}
}
}
func TestAlertDigestRendersEnhancedOrHigherSPCRiskWithoutAlerts(t *testing.T) {
tests := []struct {
label string
risk string
}{
{label: "Enhanced Risk", risk: "Enhanced risk"},
{label: "Moderate Risk", risk: "Moderate risk"},
{label: "High Risk", risk: "High risk"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{Title: "Hourly Report"},
GeneratedText: testGeneratedText{
Summary: "Severe storms are possible.",
ForecastDiscussion: "SPC outlooks highlight the risk.",
},
Modules: testModules{
CurrentConditions: &testCurrentConditions{},
HourlyForecast: &testHourlyForecast{},
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: tt.label, RiskLabel: tt.risk, PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"## Alert Digest",
"- **SPC Convective Outlook**: " + tt.risk + " for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
})
}
}
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{

View File

@@ -0,0 +1,76 @@
package reporttemplate
import (
"reflect"
"strings"
"text/template"
)
func templateFuncs() template.FuncMap {
return template.FuncMap{
"hasRelevantAlerts": hasRelevantAlerts,
"hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk,
"isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk,
}
}
func hasRelevantAlerts(alertDigest any) bool {
value := dereferenceValue(reflect.ValueOf(alertDigest))
if !value.IsValid() || value.Kind() != reflect.Struct {
return false
}
relevant := value.FieldByName("Relevant")
return relevant.IsValid() && relevant.Kind() == reflect.Slice && relevant.Len() > 0
}
func hasEnhancedOrHigherSPCRisk(outlooks any) bool {
value := dereferenceValue(reflect.ValueOf(outlooks))
if !value.IsValid() || value.Kind() != reflect.Struct {
return false
}
riskDigest := value.FieldByName("RiskDigest")
if !riskDigest.IsValid() || riskDigest.Kind() != reflect.Slice {
return false
}
for i := 0; i < riskDigest.Len(); i++ {
if isEnhancedOrHigherSPCRisk(riskDigest.Index(i).Interface()) {
return true
}
}
return false
}
func isEnhancedOrHigherSPCRisk(risk any) bool {
value := dereferenceValue(reflect.ValueOf(risk))
if !value.IsValid() || value.Kind() != reflect.Struct {
return false
}
label := stringField(value, "LabelText")
if label == "" {
label = stringField(value, "RiskLabel")
}
switch strings.ToLower(strings.TrimSpace(label)) {
case "enhanced risk", "moderate risk", "high risk":
return true
default:
return false
}
}
func dereferenceValue(value reflect.Value) reflect.Value {
for value.IsValid() && (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) {
if value.IsNil() {
return reflect.Value{}
}
value = value.Elem()
}
return value
}
func stringField(value reflect.Value, name string) string {
field := value.FieldByName(name)
if !field.IsValid() || field.Kind() != reflect.String {
return ""
}
return field.String()
}

View File

@@ -1,8 +1,8 @@
{{ define "alert_digest" }}{{ if or (and .Modules.AlertDigest .Modules.AlertDigest.Relevant) (and .Modules.SPCConvectiveOutlooks .Modules.SPCConvectiveOutlooks.RiskDigest) }}## Alert Digest
{{ define "alert_digest" }}{{ $hasAlerts := hasRelevantAlerts .Modules.AlertDigest }}{{ $hasSPC := hasEnhancedOrHigherSPCRisk .Modules.SPCConvectiveOutlooks }}{{ if or $hasAlerts $hasSPC }}## Alert Digest
{{ with .Modules.AlertDigest }}{{ range .Relevant -}}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.{{ with .Instruction }} {{ . }}{{ else }}{{ with .Description }} {{ . }}{{ end }}{{ end }}
{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest -}}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.
{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest }}{{ if isEnhancedOrHigherSPCRisk . -}}
- **SPC Convective Outlook**: {{ with .RiskLabel }}{{ . }}{{ else }}Convective risk{{ end }} for severe thunderstorms in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.
{{ end }}{{ end }}
{{ end }}{{ end }}{{ end }}
{{ end }}{{ end }}