Compare commits
6 Commits
v0.8.0
...
3aaddda676
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaddda676 | |||
| 7f989839cd | |||
| 27506168f8 | |||
| dc11e08e22 | |||
| fdddb5f08d | |||
| f78186b020 |
@@ -43,7 +43,7 @@ config test suite.
|
|||||||
|
|
||||||
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
|
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
|
||||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
- `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`.
|
- `units`: Weather API units query value. Default: `us`.
|
||||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||||
|
|||||||
@@ -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`.
|
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
|
## Response Envelope
|
||||||
|
|
||||||
Every response used by the adapter must be JSON with a top-level `data` field:
|
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
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
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
|
## Query Parameters
|
||||||
|
|
||||||
The adapter sends these 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`
|
- `format`: from `weather_api.format`; configuration validation requires `json`
|
||||||
- `units`: from `weather_api.units`
|
- `units`: from `weather_api.units`
|
||||||
- `precision`: from `weather_api.precision` on observations, current
|
- `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,
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||||
discussion, and SPC convective outlook requests
|
discussion, and SPC convective outlook requests
|
||||||
|
|
||||||
@@ -116,7 +134,10 @@ bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
|
|||||||
|
|
||||||
## Endpoints Used
|
## 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`
|
- `/observations`
|
||||||
- `/conditions/current`
|
- `/conditions/current`
|
||||||
|
|||||||
@@ -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
|
module context. `internal/facts` consumes daypart configuration before module
|
||||||
builders run. Configured `location` values are prompt context only; Weather API
|
builders run. Configured `location` values are prompt context only; Weather API
|
||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
`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
|
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||||
subset of discussion fields. Hourly Report defaults this module to
|
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
|
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||||
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
||||||
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
|
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`
|
outlook contains the configured location. It enriches matching outlooks with
|
||||||
for categorical outlooks that overlap the report period, contain the location,
|
embedded background definitions owned by this package. It also emits a curated
|
||||||
and meet the configured-in-code minimum severity for report rendering. It does
|
`risk_digest` for categorical outlooks that overlap the report period, contain
|
||||||
not emit GeoJSON geometry, source URL, expiration time, or severity rank.
|
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
|
Prompt-facing module intervals use friendly local `period_begins` and
|
||||||
`period_ends` labels. Canonical report metadata, source provenance,
|
`period_ends` labels. Canonical report metadata, source provenance,
|
||||||
|
|||||||
@@ -229,9 +229,11 @@ validation.
|
|||||||
- `risk_digest`
|
- `risk_digest`
|
||||||
|
|
||||||
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||||
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
`background_definition`, `period_begins`, `period_ends`, `issued_at`,
|
||||||
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
`contains_location`, and `image_url`. `background_definition` is embedded
|
||||||
severity rank.
|
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
|
The optional `risk_digest` list is a curated report-rendering subset of
|
||||||
categorical outlooks that overlap the report period, contain the configured
|
categorical outlooks that overlap the report period, contain the configured
|
||||||
|
|||||||
@@ -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
|
template at render time and receive the same typed render context as the
|
||||||
caller. The `alert_digest` partial renders the combined Alerts and Risk
|
caller. The `alert_digest` partial renders the combined Alerts and Risk
|
||||||
Products section from relevant NWS alerts and curated SPC outlook digest
|
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
|
## Schema Contract
|
||||||
|
|
||||||
|
|||||||
304
docs/roadmap/promptkit.md
Normal file
304
docs/roadmap/promptkit.md
Normal 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?
|
||||||
@@ -367,6 +367,10 @@ YAML.
|
|||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable 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[].PeriodBegins` | string | Friendly outlook period start. |
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Relevant docs: [CLI reference](cli.md).
|
|||||||
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
|
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
|
||||||
|
|
||||||
Likely cause: the configured Weather API endpoint is unreachable, returned a
|
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:
|
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
|
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
|
||||||
service response. The adapter fetches `/observations`, `/conditions/current`,
|
service response. The adapter first warms up `/conditions/current`, then fetches
|
||||||
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
|
`/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).
|
Relevant docs: [Configuration reference](config.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.example.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 0
|
||||||
units: us
|
units: us
|
||||||
timezone: "America/Chicago"
|
timezone: "America/Chicago"
|
||||||
format: json
|
format: json
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import (
|
|||||||
const (
|
const (
|
||||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
|
||||||
|
defaultWarmupEndpoint = "/conditions/current"
|
||||||
|
defaultWarmupAttempts = 3
|
||||||
|
defaultWarmupDelay = time.Second
|
||||||
|
defaultFetchAttempts = 2
|
||||||
|
defaultFetchRetryDelay = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -35,6 +41,12 @@ type Client struct {
|
|||||||
precision int
|
precision int
|
||||||
missingSource config.MissingSourceConfig
|
missingSource config.MissingSourceConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
|
warmupEndpoint string
|
||||||
|
warmupAttempts int
|
||||||
|
warmupDelay time.Duration
|
||||||
|
fetchAttempts int
|
||||||
|
fetchRetryDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Client)
|
type Option func(*Client)
|
||||||
@@ -80,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
Default: cfg.MissingSource.Default,
|
Default: cfg.MissingSource.Default,
|
||||||
Sources: cfg.MissingSource.Sources,
|
Sources: cfg.MissingSource.Sources,
|
||||||
},
|
},
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
|
warmupEndpoint: defaultWarmupEndpoint,
|
||||||
|
warmupAttempts: defaultWarmupAttempts,
|
||||||
|
warmupDelay: defaultWarmupDelay,
|
||||||
|
fetchAttempts: defaultFetchAttempts,
|
||||||
|
fetchRetryDelay: defaultFetchRetryDelay,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(client)
|
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) {
|
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||||
|
if err := c.warmup(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
fetchedAt := c.now()
|
fetchedAt := c.now()
|
||||||
builder := bundleBuilder{
|
builder := bundleBuilder{
|
||||||
client: c,
|
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) {
|
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||||
reqURL := c.endpointURL(endpoint, opts)
|
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
return nil, weatherdata.Source{}, 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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var env envelope
|
var env envelope
|
||||||
@@ -440,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
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 {
|
func isJSONNull(raw json.RawMessage) bool {
|
||||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
"/weatherstories/latest",
|
"/weatherstories/latest",
|
||||||
convectiveOutlooksEndpoint,
|
convectiveOutlooksEndpoint,
|
||||||
}
|
}
|
||||||
if len(requested) != len(wantPaths) {
|
if len(requested) != len(wantPaths)+1 {
|
||||||
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
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 {
|
for _, want := range wantPaths {
|
||||||
if !containsPath(requested, want) {
|
if !containsPath(requested, want) {
|
||||||
@@ -132,9 +135,15 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
t.Fatalf("request %q missing units=us", rawURL)
|
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=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
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) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
|
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||||
}, nil)
|
}, nil)
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
@@ -194,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchBundle() error = nil, want HTTP error")
|
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())
|
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) {
|
func TestRequiredHourlyForecast(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
}, nil)
|
}, &requested)
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
_, err := client.FetchBundle(context.Background())
|
_, err := client.FetchBundle(context.Background())
|
||||||
@@ -212,6 +346,9 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "hourly forecast data") {
|
if !strings.Contains(err.Error(), "hourly forecast data") {
|
||||||
t.Fatalf("error = %q, want hourly context", err.Error())
|
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) {
|
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) {
|
func TestHTTPTimeout(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) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
@@ -432,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
client.warmupDelay = 0
|
||||||
|
client.fetchRetryDelay = 0
|
||||||
|
|
||||||
_, err = client.FetchBundle(context.Background())
|
_, err = client.FetchBundle(context.Background())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchBundle() error = nil, want timeout error")
|
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())
|
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,8 +640,9 @@ func TestSaveBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type handlerOverride struct {
|
type handlerOverride struct {
|
||||||
status int
|
status int
|
||||||
body string
|
body string
|
||||||
|
handler http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -485,6 +662,10 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
*requested = append(*requested, r.URL.String())
|
*requested = append(*requested, r.URL.String())
|
||||||
}
|
}
|
||||||
if override, ok := overrides[r.URL.Path]; ok {
|
if override, ok := overrides[r.URL.Path]; ok {
|
||||||
|
if override.handler != nil {
|
||||||
|
override.handler(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(override.status)
|
w.WriteHeader(override.status)
|
||||||
_, _ = w.Write([]byte(override.body))
|
_, _ = w.Write([]byte(override.body))
|
||||||
return
|
return
|
||||||
@@ -510,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
client.warmupDelay = 0
|
||||||
|
client.fetchRetryDelay = 0
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +715,16 @@ func containsPath(requested []string, path string) bool {
|
|||||||
return false
|
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 {
|
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
|
|||||||
@@ -390,8 +390,12 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
||||||
}
|
}
|
||||||
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
metadataStanza, ok := savedDataPackage.Briefing.Values["metadata"].(map[string]any)
|
||||||
t.Fatal("data package metadata stanza missing")
|
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)
|
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
|
||||||
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
|
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
|
||||||
@@ -572,6 +576,10 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
|||||||
" spc_convective_discussion:",
|
" spc_convective_discussion:",
|
||||||
" included_because: categorical severity_rank >= 3",
|
" included_because: categorical severity_rank >= 3",
|
||||||
" label_text: Slight Risk",
|
" 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_begins:",
|
||||||
" period_ends:",
|
" period_ends:",
|
||||||
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
|
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
|
||||||
@@ -753,7 +761,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
|||||||
"# Hourly Report",
|
"# Hourly Report",
|
||||||
"Storm chances increase through late morning.",
|
"Storm chances increase through late morning.",
|
||||||
"## Alert Digest",
|
"## 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",
|
"## Precipitation Timing",
|
||||||
"A cold front is moving into the region.",
|
"A cold front is moving into the region.",
|
||||||
"A front will keep the region unsettled.",
|
"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)
|
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 {
|
if result.OutputPath != result.ReportPath {
|
||||||
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
|
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -253,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
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)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal metadata: %v", err)
|
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") {
|
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)
|
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) {
|
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type MetadataModule struct {
|
|||||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
Location *LocationContext `json:"location,omitempty"`
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceWarningSummary struct {
|
type SourceWarningSummary struct {
|
||||||
@@ -44,7 +43,6 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Location),
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
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
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
37
internal/briefing/spc_convective_outlook_definitions.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -25,15 +25,22 @@ type SPCConvectiveOutlooksModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SPCConvectiveOutlookRecord struct {
|
type SPCConvectiveOutlookRecord struct {
|
||||||
Day int `json:"day,omitempty"`
|
Day int `json:"day,omitempty"`
|
||||||
OutlookType string `json:"outlook_type,omitempty"`
|
OutlookType string `json:"outlook_type,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
LabelText string `json:"label_text,omitempty"`
|
LabelText string `json:"label_text,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
|
||||||
PeriodEnds string `json:"period_ends,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
IssuedAt string `json:"issued_at,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
ContainsLocation bool `json:"contains_location"`
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
ImageURL string `json:"image_url,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 {
|
type SPCConvectiveOutlookDigest struct {
|
||||||
@@ -87,15 +94,16 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
records = append(records, SPCConvectiveOutlookRecord{
|
records = append(records, SPCConvectiveOutlookRecord{
|
||||||
Day: outlook.Day,
|
Day: outlook.Day,
|
||||||
OutlookType: outlook.OutlookType,
|
OutlookType: outlook.OutlookType,
|
||||||
Label: outlook.Label,
|
Label: outlook.Label,
|
||||||
LabelText: outlook.LabelText,
|
LabelText: outlook.LabelText,
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||||
ContainsLocation: outlook.ContainsLocation,
|
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||||
ImageURL: outlook.ImageURL,
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
ImageURL: outlook.ImageURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return records
|
return records
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
|||||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
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" {
|
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)
|
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)
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
}
|
}
|
||||||
text := string(data)
|
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) {
|
if !strings.Contains(text, field) {
|
||||||
t.Fatalf("json = %s, want field %s", 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) {
|
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||||
if defaultSPCRiskDigestOutlookType != "categorical" {
|
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||||
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Format != "json" {
|
if cfg.WeatherAPI.Format != "json" {
|
||||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||||
}
|
}
|
||||||
|
if cfg.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" {
|
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)
|
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func Defaults() Config {
|
|||||||
return Config{
|
return Config{
|
||||||
WeatherAPI: WeatherAPIConfig{
|
WeatherAPI: WeatherAPIConfig{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Precision: 1,
|
Precision: 0,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
Format: "json",
|
Format: "json",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
|
|||||||
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
|
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
|
||||||
"Storm chances increase through late morning.",
|
"Storm chances increase through late morning.",
|
||||||
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
|
"- **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 cold front is moving into the region.",
|
||||||
"A front will keep the region unsettled.",
|
"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)
|
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) {
|
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func Render(id string, data any) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse report template %q: %w", id, err)
|
return nil, fmt.Errorf("parse report template %q: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,8 +270,7 @@ func TestRenderHourly(t *testing.T) {
|
|||||||
"Storm chances increase through late morning.",
|
"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.",
|
"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",
|
"## 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.",
|
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.",
|
||||||
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.",
|
|
||||||
"- **9:00 AM:** 74°F and cloudy.",
|
"- **9:00 AM:** 74°F and cloudy.",
|
||||||
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
|
"- **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.",
|
"- **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") {
|
if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") {
|
||||||
t.Fatalf("rendered template included omitted details:\n%s", text)
|
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{
|
assertOrderedText(t, text, []string{
|
||||||
"# Hourly Report",
|
"# Hourly Report",
|
||||||
"## Alert Digest",
|
"## Alert Digest",
|
||||||
@@ -371,8 +375,7 @@ func TestRenderTomorrow(t *testing.T) {
|
|||||||
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
||||||
"Tomorrow starts dry before showers return later in the day.",
|
"Tomorrow starts dry before showers return later in the day.",
|
||||||
"## Alert Digest",
|
"## 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.",
|
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
|
||||||
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
|
|
||||||
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
|
"- **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.",
|
"- **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%.",
|
"- **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{
|
for _, unwanted := range []string{
|
||||||
"upper 50s.\n\n- **Morning:**",
|
"upper 50s.\n\n- **Morning:**",
|
||||||
"upper 60s.\n\n- **Afternoon:**",
|
"upper 60s.\n\n- **Afternoon:**",
|
||||||
|
"Secure outdoor objects.",
|
||||||
|
"Slight risk for severe thunderstorms",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(text, unwanted) {
|
if strings.Contains(text, unwanted) {
|
||||||
t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text)
|
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",
|
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
||||||
"The selected day starts dry before showers return later in the day.",
|
"The selected day starts dry before showers return later in the day.",
|
||||||
"## Alert Digest",
|
"## 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.",
|
"- **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.",
|
"- **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%.",
|
"- **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)
|
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{
|
assertOrderedText(t, text, []string{
|
||||||
"# Monday's Weather",
|
"# Monday's Weather",
|
||||||
"The selected day starts dry before showers return later in the day.",
|
"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",
|
"**Updated:** Monday, June 15, 2026 at 7:14 AM",
|
||||||
"Today starts dry before showers return later in the day.",
|
"Today starts dry before showers return later in the day.",
|
||||||
"## Alert Digest",
|
"## 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.",
|
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
|
||||||
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
|
|
||||||
"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.",
|
"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.",
|
"- **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%.",
|
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
|
||||||
@@ -606,7 +613,7 @@ func TestRenderToday(t *testing.T) {
|
|||||||
"## Precipitation Timing",
|
"## Precipitation Timing",
|
||||||
"## Forecast Discussion",
|
"## 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) {
|
if strings.Contains(text, unwanted) {
|
||||||
t.Fatalf("rendered template included %q:\n%s", unwanted, text)
|
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) {
|
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
|
||||||
rendered, err := Render("hourly", testRenderContext{
|
rendered, err := Render("hourly", testRenderContext{
|
||||||
Report: testReportContext{
|
Report: testReportContext{
|
||||||
|
|||||||
76
internal/reporttemplate/template_funcs.go
Normal file
76
internal/reporttemplate/template_funcs.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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 -}}
|
{{ 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 }}
|
- **{{ 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 -}}
|
{{ 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 }}.
|
- **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 }}
|
{{ end }}{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user