Compare commits
29 Commits
v0.6.0
...
1ddd88231a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ddd88231a | |||
| d665049f05 | |||
| 1af6169999 | |||
| 468197f7e0 | |||
| 816cfb24aa | |||
| 479d144592 | |||
| 0b516d9762 | |||
| 2483c2362d | |||
| 40639309b1 | |||
| eefb0681dc | |||
| 9501dad1dc | |||
| 24dba3bd60 | |||
| e9508089ab | |||
| 454f47b2b5 | |||
| d8b417458b | |||
| 195a130124 | |||
| 8577fc29e4 | |||
| d71c7e4d28 | |||
| 9bc8156615 | |||
| 183b23cf5a | |||
| 138bc4e7e4 | |||
| e2529cfdf2 | |||
| b982b27f84 | |||
| c573cd5b4d | |||
| c2758d7a91 | |||
| 64cae8c4d9 | |||
| a2ba6f5382 | |||
| 7c8d9191c1 | |||
| 9677835d84 |
@@ -1,9 +1,10 @@
|
||||
# weatherreporter
|
||||
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data. It builds structured briefing packages,
|
||||
runs them through `scriptorium`, and keeps inspectable artifacts under a local
|
||||
workspace.
|
||||
reports from normalized forecast data. It builds JSON module snapshots, passes
|
||||
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
|
||||
under a local workspace. It can also upload successfully generated managed
|
||||
Markdown reports to a configured `distributor` HTTP upload endpoint.
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
||||
30
docs/cli.md
30
docs/cli.md
@@ -1,7 +1,7 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||
batches, and inspects previously generated artifacts.
|
||||
batches, and inspects stored artifacts.
|
||||
|
||||
## Shortest Useful Command
|
||||
|
||||
@@ -11,7 +11,9 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`.
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
||||
distributor notification is enabled in configuration, the command also uploads
|
||||
the managed Markdown report after final metadata is saved.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -26,21 +28,27 @@ weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-d
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
```
|
||||
|
||||
`generate` commands write briefing, data package, preflight, report, and
|
||||
metadata artifacts under the configured workspace. `generate storm` requires
|
||||
explicit event-window bounds with `--start` and `--end`.
|
||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
||||
`--out` writes an extra Markdown copy for the operator; distributor
|
||||
notification uses the managed report path, not the extra copy. `generate storm`
|
||||
requires explicit event-window bounds with `--start` and `--end`.
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed.
|
||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||
notification uses each managed report path, not the extra copies. When
|
||||
notification is enabled, batch summaries and status lines include notification
|
||||
status, accepted distributor run ID, or notification error fields for each
|
||||
attempted report.
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
@@ -61,6 +69,9 @@ They do not fetch weather data or invoke `scriptorium`.
|
||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||
timestamps with explicit offsets.
|
||||
|
||||
Distributor notification is configured only through `notify.distributor`; there
|
||||
are no distributor-specific CLI flags.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
```sh
|
||||
@@ -77,14 +88,15 @@ weatherreporter run evening --out-dir ./reports
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
warning counts. The other inspect commands require a RunID. `inspect prior`
|
||||
warning counts. The other inspect commands require a RunID. `inspect modules`
|
||||
returns the persisted ordered module snapshot for a run. `inspect prior`
|
||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||
`null` when none exists. `inspect sources` shows source provenance and source
|
||||
warnings without dumping full weather payloads.
|
||||
|
||||
122
docs/config.md
122
docs/config.md
@@ -16,9 +16,9 @@ Precedence is:
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
|
||||
The implemented configuration overrides are `--units` and `--tz`. Output flags
|
||||
control report copies for the current command but do not change configuration
|
||||
files. Environment-variable configuration is not implemented.
|
||||
The CLI configuration overrides are `--units` and `--tz`. Output flags control
|
||||
report copies for the current command but do not change configuration files.
|
||||
Environment variables do not override configuration fields.
|
||||
|
||||
## Minimal Config
|
||||
|
||||
@@ -53,7 +53,7 @@ Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
|
||||
### `location`
|
||||
|
||||
`location` is descriptive prompt context included in briefing metadata and
|
||||
`location` is descriptive prompt context included in module metadata and
|
||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||
multiple configured forecast locations.
|
||||
|
||||
@@ -64,6 +64,66 @@ multiple configured forecast locations.
|
||||
The prompt-facing location object also includes `timezone`, derived from the
|
||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||
|
||||
### `secrets`
|
||||
|
||||
- `directory`: optional directory of file-backed environment secrets. Default:
|
||||
empty, which disables secret loading.
|
||||
|
||||
When configured, each regular file directly under `secrets.directory` is loaded
|
||||
after config file parsing and CLI overrides. The file basename must be a valid
|
||||
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
|
||||
become the environment variable value and overwrite any existing value. One
|
||||
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
|
||||
missing directories, and unreadable files fail config loading.
|
||||
|
||||
### `notify`
|
||||
|
||||
`notify.distributor` controls distributor notification after successful report
|
||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||
weatherreporter uploads one distributor bundle per generated report after
|
||||
`scriptorium run` succeeds and final metadata is saved.
|
||||
|
||||
- `enabled`: whether distributor notification config is active. Default:
|
||||
`false`.
|
||||
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
|
||||
Default: `https://distributor.example.com`.
|
||||
- `token_env`: environment variable name that will contain the distributor
|
||||
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
|
||||
- `timeout`: distributor operation timeout. Must be greater than zero when
|
||||
enabled. Default: `30s`.
|
||||
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
||||
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
||||
when enabled. Default: empty.
|
||||
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
||||
`weatherreporter.{location_id}.{report_id}`.
|
||||
- `idempotency_key_template`: template for distributor idempotency keys.
|
||||
Default: `{bundle_id}.{run_id}`.
|
||||
- `report_path_templates`: ordered list of templates for Markdown report paths
|
||||
inside the distributor bundle. Each rendered path maps to the same managed
|
||||
Markdown report source. Default:
|
||||
```yaml
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
```
|
||||
|
||||
Supported template variables are `location_id`, `report_id`, `run_id`,
|
||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
|
||||
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
||||
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
||||
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
|
||||
|
||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||
workflow. The rendered bundle ID is the stable logical source identity for the
|
||||
report stream. The rendered idempotency key is the per-run retry identity.
|
||||
|
||||
Rendered report paths must be unique relative paths with `/` separators. They
|
||||
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
||||
or `.distributor.json`.
|
||||
|
||||
The upload token is read from the environment variable named by `token_env`
|
||||
after config loading and `secrets.directory` processing. Config files should
|
||||
name the variable only; they should not contain the token value.
|
||||
|
||||
### `missing_source`
|
||||
|
||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||
@@ -83,10 +143,11 @@ stub source slots use the missing-source policy.
|
||||
### `workspace`
|
||||
|
||||
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
||||
- `snapshots_dir`: briefing and metadata directory under `workspace.root`. Default: `snapshots`.
|
||||
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
|
||||
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
||||
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
||||
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
||||
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
||||
|
||||
Workspace subdirectories must be relative paths that stay inside
|
||||
`workspace.root`.
|
||||
@@ -110,19 +171,62 @@ midday, afternoon, and evening.
|
||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
||||
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
||||
|
||||
Recent Changes are added to prompt input when a prior comparable briefing
|
||||
Recent Changes are added to prompt input when a prior comparable module
|
||||
snapshot exists and a threshold is crossed.
|
||||
|
||||
### `reports`
|
||||
|
||||
`reports` optionally overrides the ordered deterministic modules declared by
|
||||
report definitions. Omit a report entry to use its default module order.
|
||||
|
||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
||||
also accepted.
|
||||
|
||||
Each report entry supports:
|
||||
|
||||
- `deterministic_modules`: ordered module list. Entries may be string module
|
||||
IDs or objects with `id` and optional `options`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
```
|
||||
|
||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||
combinations, duplicate stanza names, and invalid options fail config loading.
|
||||
`area_forecast_discussion.options.sections` may contain `product`,
|
||||
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
||||
includes all available AFD sections.
|
||||
|
||||
The module registry accepts all module IDs documented in
|
||||
[Module Contract Internals](internal/module.md). Modules without builders are
|
||||
valid in composition but do not emit YAML stanzas.
|
||||
|
||||
## Secrets
|
||||
|
||||
Configuration files should not contain secrets. The current Weather API and
|
||||
Scriptorium integration settings do not require secret fields.
|
||||
Configuration files should not contain raw secrets. Use `secrets.directory` to
|
||||
load secret values from files into environment variables for integrations that
|
||||
read credentials from the environment. Secret file names become environment
|
||||
variable names, and secret file contents become values. For distributor
|
||||
notification, this allows a file such as
|
||||
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
|
||||
`notify.distributor.token_env`.
|
||||
|
||||
## Maintained Examples
|
||||
|
||||
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
|
||||
useful config for generation and fetching.
|
||||
- [examples/config.yml](../examples/config.yml): production-oriented config
|
||||
covering implemented fields.
|
||||
covering maintained fields.
|
||||
|
||||
Both example files are loaded by the config test suite.
|
||||
|
||||
136
docs/integrations/distributor/api.md
Normal file
136
docs/integrations/distributor/api.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Upstream Producer Integration
|
||||
|
||||
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
|
||||
|
||||
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
The upstream application needs these values from deployment or operator configuration:
|
||||
|
||||
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
|
||||
- upload token: bearer token that authenticates the producer;
|
||||
- pipeline id: configured `http_upload` pipeline that should process this upload;
|
||||
- generated files: regular local files to include in the source bundle;
|
||||
- bundle id: stable identifier for the logical report stream or artifact;
|
||||
- idempotency key: unique key for one producer run, reused only when retrying that same run.
|
||||
|
||||
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
|
||||
|
||||
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
|
||||
|
||||
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
|
||||
|
||||
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
|
||||
|
||||
Add the dependency from the upstream application:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/distributor
|
||||
```
|
||||
|
||||
## Minimal Go Example
|
||||
|
||||
```go
|
||||
package reports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
)
|
||||
|
||||
func SubmitReport(reportPath, summaryPath string) error {
|
||||
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
|
||||
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
|
||||
if endpoint == "" || token == "" {
|
||||
return fmt.Errorf("distributor endpoint and token are required")
|
||||
}
|
||||
|
||||
pipelineID := "weather-hourly"
|
||||
reportID := "weather.hourly.brentwood"
|
||||
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := upload.NewClient(upload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||
PipelineID: pipelineID,
|
||||
ID: reportID,
|
||||
IdempotencyKey: reportID + "." + runID,
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: reportPath, Path: "report.md"},
|
||||
{SourcePath: summaryPath, Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
var conflict *upload.IdempotencyConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("distributor accepted run %s\n", result.RunID)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Producer Responsibilities
|
||||
|
||||
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
|
||||
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
|
||||
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
|
||||
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
|
||||
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
|
||||
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
|
||||
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
|
||||
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
||||
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
||||
|
||||
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
|
||||
|
||||
## Idempotency And Status
|
||||
|
||||
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
|
||||
|
||||
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
|
||||
|
||||
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
|
||||
|
||||
Optional status check:
|
||||
|
||||
```go
|
||||
status, err := client.Status(ctx, result.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
In the `distributor` source tree:
|
||||
|
||||
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
|
||||
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
|
||||
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
|
||||
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.
|
||||
90
docs/integrations/distributor/pkg-bundle.md
Normal file
90
docs/integrations/distributor/pkg-bundle.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `pkg/bundle`
|
||||
|
||||
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
|
||||
|
||||
Import path:
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
```
|
||||
|
||||
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
|
||||
|
||||
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
|
||||
|
||||
## Preferred Complete-Bundle Workflow
|
||||
|
||||
Use `WriteBundle` when producer-generated files live outside the final bundle root.
|
||||
|
||||
```go
|
||||
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
|
||||
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
|
||||
ID: "weather.hourly.brentwood",
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = manifest
|
||||
```
|
||||
|
||||
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
|
||||
|
||||
## Existing Bundle Root Workflow
|
||||
|
||||
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
|
||||
|
||||
```go
|
||||
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
|
||||
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
|
||||
Root: root,
|
||||
ID: "weather.hourly.brentwood",
|
||||
Files: []string{"report.md", "summary.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundle.ValidateBundle(root, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
|
||||
|
||||
## Paths And Ordering
|
||||
|
||||
Bundle paths are slash-separated paths relative to the bundle root.
|
||||
|
||||
Invalid paths include:
|
||||
|
||||
- empty paths;
|
||||
- absolute paths;
|
||||
- paths containing backslashes;
|
||||
- `.` or `..` path segments;
|
||||
- empty path segments;
|
||||
- any basename of `manifest.json` or `.distributor.json`.
|
||||
|
||||
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
||||
|
||||
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
|
||||
|
||||
## Validation And Digest Helpers
|
||||
|
||||
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
|
||||
|
||||
Useful helpers:
|
||||
|
||||
- `LoadManifest`: read `manifest.json` from a bundle root.
|
||||
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
|
||||
- `ValidateManifest`: validate manifest-only semantics.
|
||||
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.
|
||||
122
docs/integrations/distributor/pkg-upload.md
Normal file
122
docs/integrations/distributor/pkg-upload.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# `pkg/upload`
|
||||
|
||||
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
|
||||
|
||||
Import path:
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
```
|
||||
|
||||
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
|
||||
|
||||
`UploadFiles` examples also use:
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
```
|
||||
|
||||
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
|
||||
|
||||
## Client Construction
|
||||
|
||||
```go
|
||||
client, err := upload.NewClient(upload.ClientOptions{
|
||||
Endpoint: "https://distributor.example.com",
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
|
||||
|
||||
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
|
||||
|
||||
## Upload Producer Files
|
||||
|
||||
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
|
||||
|
||||
```go
|
||||
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||
PipelineID: "weather-hourly",
|
||||
ID: "weather.hourly.brentwood",
|
||||
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = result.RunID
|
||||
```
|
||||
|
||||
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
|
||||
|
||||
## Upload An Existing Bundle
|
||||
|
||||
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
|
||||
|
||||
```go
|
||||
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
|
||||
PipelineID: "weather-hourly",
|
||||
Root: "/var/spool/weather/hourly-2026-06-07T15",
|
||||
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = result.RunID
|
||||
```
|
||||
|
||||
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
|
||||
|
||||
## Result And Status
|
||||
|
||||
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
|
||||
|
||||
Poll status while the server retains the in-memory run record:
|
||||
|
||||
```go
|
||||
status, err := client.Status(ctx, result.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||
}
|
||||
```
|
||||
|
||||
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
|
||||
|
||||
## Idempotency And Retry
|
||||
|
||||
Every upload request includes `Idempotency-Key`.
|
||||
|
||||
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
|
||||
|
||||
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
|
||||
|
||||
The client retries only safe cases:
|
||||
|
||||
- `503 Service Unavailable`;
|
||||
- temporary network errors;
|
||||
- ambiguous mid-upload failures.
|
||||
|
||||
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
|
||||
|
||||
Detect conflicting key reuse with `errors.As`:
|
||||
|
||||
```go
|
||||
var conflict *upload.IdempotencyConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.
|
||||
@@ -30,8 +30,9 @@ scriptorium run \
|
||||
```
|
||||
|
||||
`weatherreporter` always passes prompt input as
|
||||
`--input data_package=<path>`. The data package is structured JSON created by
|
||||
`internal/promptinput`.
|
||||
`--input data_package=<path>`. The data package is structured YAML created by
|
||||
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||
inspection and Recent Changes.
|
||||
|
||||
## Configured Arguments
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This document describes the external Weather API contract used by
|
||||
## Purpose
|
||||
|
||||
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
||||
weather source data and assemble a `forecast.Bundle`. This is an integration
|
||||
weather source data and assemble a `weatherdata.Bundle`. This is an integration
|
||||
contract for the project adapter, not a complete public API reference for the
|
||||
upstream service.
|
||||
|
||||
@@ -45,8 +45,7 @@ they fail the fetch; optional malformed sources follow the missing-source policy
|
||||
|
||||
The adapter sends these query parameters:
|
||||
|
||||
- `format`: from `weather_api.format`; the implemented configuration requires
|
||||
`json`
|
||||
- `format`: from `weather_api.format`; configuration validation requires `json`
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# App Orchestration Internals
|
||||
|
||||
This document describes the implemented workflow coordinator in `internal/app`.
|
||||
This document describes the workflow coordinator in `internal/app`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||
loading are complete. It resolves report definitions, fetches weather data,
|
||||
builds briefing and prompt-input artifacts, invokes Scriptorium through the
|
||||
adapter boundary, persists managed state, runs batches, and reads existing
|
||||
artifacts for inspection.
|
||||
builds collected and derived facts, builds module snapshots and prompt-input
|
||||
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
||||
notifies distributor through an app-owned notifier boundary, persists managed
|
||||
state, runs batches, and reads existing artifacts for inspection.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -17,41 +18,45 @@ Inputs:
|
||||
- `GenerateRequest` for one report command
|
||||
- `BatchRequest` for morning or evening batch commands
|
||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
||||
and internal composition
|
||||
- `ReportRequest` for single-report generation
|
||||
- resolved report definitions from `internal/report`
|
||||
- forecast bundles from `internal/adapters/weatherapi`
|
||||
- weather data bundles from `internal/adapters/weatherapi`
|
||||
- prior snapshots loaded from `internal/state`
|
||||
- optional renderer and state-store fakes for tests
|
||||
- optional renderer, notifier, and state-store fakes for tests
|
||||
|
||||
Outputs:
|
||||
|
||||
- generated report results with briefing, data package, preflight, report,
|
||||
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
||||
- batch summaries with per-report status, artifact paths, and error text
|
||||
- generated report results with JSON module snapshot, YAML data package,
|
||||
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
||||
result details, and notification result when attempted
|
||||
- batch summaries with per-report status, artifact paths, error text, and
|
||||
notification outcome when attempted
|
||||
- saved Weather API bundle JSON for fetch workflows
|
||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
||||
snapshots, and source provenance
|
||||
- inspection JSON values for reports, metadata, module snapshots, data
|
||||
packages, prior snapshots, and source provenance
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/app` owns workflow order and request composition. It does not parse
|
||||
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
|
||||
facts, define report periods, compare rendered Markdown, or construct
|
||||
Scriptorium argv.
|
||||
CLI flags, load YAML files directly, implement HTTP transport, own fact
|
||||
derivation algorithms, define report periods, compare rendered Markdown, or
|
||||
construct Scriptorium argv.
|
||||
|
||||
Report selection and report identity policy come from `internal/report`.
|
||||
Collected and derived fact contracts come from `internal/facts`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
|
||||
and persisted metadata stay in `internal/state`.
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
||||
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||
persisted metadata stay in `internal/state`.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `weather_api.*` for Weather API client construction and briefing metadata
|
||||
- `weather_api.*` for Weather API client construction and module metadata
|
||||
- `scriptorium.*` for renderer construction
|
||||
- `workspace.*` for filesystem state
|
||||
- `dayparts` for daily and outlook summarization
|
||||
- `recent_change.*` for structured Recent Changes thresholds
|
||||
- `notify.distributor.*` for optional notification after report generation
|
||||
|
||||
Output copy flags are command request fields. They are not configuration
|
||||
defaults.
|
||||
@@ -64,21 +69,32 @@ Single-report generation follows this order:
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build a report-specific briefing package.
|
||||
6. Save the briefing snapshot.
|
||||
7. Compute Recent Changes from structured prior and current briefings.
|
||||
8. Build and save the Scriptorium `data_package`.
|
||||
5. Build collected and derived facts once.
|
||||
6. Execute configured modules and save the module snapshot.
|
||||
7. Compute Recent Changes from structured prior and current module snapshots.
|
||||
8. Build and save the YAML Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
15. If distributor notification is enabled, notify using the managed report
|
||||
path as the source file.
|
||||
16. Save a distributor notification debug artifact and update metadata with its
|
||||
path.
|
||||
|
||||
If render preflight returns both a result and an error, preflight JSON and
|
||||
metadata are persisted before the error is returned. If Scriptorium report
|
||||
generation returns an error after writing output, the managed report and
|
||||
metadata remain inspectable.
|
||||
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||
failures.
|
||||
When notification is attempted, the debug artifact records request identity,
|
||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||
distributor status fields, raw status report JSON when available, and redacted
|
||||
failure context.
|
||||
`--out` copies are never used as notification source files.
|
||||
|
||||
## Batch Workflow
|
||||
|
||||
@@ -87,6 +103,10 @@ on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
When notification is enabled, each successfully generated report is notified
|
||||
independently. Notification failure marks that report failed, records
|
||||
notification fields in the batch result, and does not stop later reports.
|
||||
`--out-dir` copies are never used as notification source files.
|
||||
|
||||
## Inspection Workflow
|
||||
|
||||
@@ -98,9 +118,12 @@ inspection view.
|
||||
## Failure Behavior
|
||||
|
||||
- Resolve errors stop the requested workflow before fetching weather data.
|
||||
- Weather API and briefing errors stop that report before Scriptorium runs.
|
||||
- Weather API and module execution errors stop that report before Scriptorium
|
||||
runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||
context and are recorded separately in batch results.
|
||||
- Metadata and artifact path errors include filesystem context.
|
||||
- Batch failures are recorded per report and surfaced through an aggregate
|
||||
batch error.
|
||||
@@ -119,5 +142,7 @@ Inspect:
|
||||
- Generated reports use the same app request and result types regardless of
|
||||
report ID.
|
||||
- Render preflight precedes Scriptorium report generation.
|
||||
- Recent Changes are computed from structured briefing snapshots.
|
||||
- Recent Changes are computed from structured module snapshots.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Distributor notification maps the managed Markdown report path to configured
|
||||
bundle paths; extra output copies are not upload sources.
|
||||
|
||||
@@ -1,47 +1,60 @@
|
||||
# Briefing Internals
|
||||
# Module Builder Internals
|
||||
|
||||
This document describes the implemented briefing package boundary.
|
||||
This document describes module builder behavior in `internal/briefing`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/briefing` builds structured report-specific briefing packages from
|
||||
resolved report metadata, forecast bundles, and derived forecast summaries.
|
||||
Briefings are curated inputs for prompt data packages, not rendered report
|
||||
prose.
|
||||
`internal/briefing` turns report metadata, collected weather data, and derived
|
||||
forecast facts into prompt-facing module outputs. The package also owns the
|
||||
module registry used to validate report composition and config overrides.
|
||||
|
||||
Module outputs are structured prompt inputs. They are not rendered report prose
|
||||
and they are not persisted by this package.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- resolved report definition, generation time, timezone, and valid period
|
||||
- forecast bundle with source provenance and warnings
|
||||
- derived daily or period summaries where required
|
||||
- collected facts built from `weatherdata.Bundle`
|
||||
- derived daily, daypart, precipitation, alert, and storm-window facts where
|
||||
required
|
||||
- configured units, timezone, and descriptive location context
|
||||
- typed module options from report defaults or config overrides
|
||||
|
||||
Outputs:
|
||||
|
||||
- `briefing.Package` with common metadata and one report-specific content
|
||||
object for Daily, 3-Day, Weekend, or Storm Report
|
||||
- optional `currentConditions` prompt context from normalized
|
||||
`/conditions/current` data when available
|
||||
- optional structured `weatherStory` context on report-specific briefing
|
||||
objects when `/weatherstories/latest` is available
|
||||
- optional JSON file written by `briefing.Save`
|
||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `alert_digest`,
|
||||
`area_forecast_discussion`, and `weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
|
||||
The registry also contains accepted composition entries for modules that do not
|
||||
emit stanzas until a builder exists. App orchestration skips those entries when
|
||||
constructing snapshots.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package selects and shapes weather facts for prompts.
|
||||
- It does not fetch weather data, compare prior snapshots, build
|
||||
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
||||
- This package selects and shapes already-collected weather facts for prompts.
|
||||
- It validates module composition against report compatibility and option
|
||||
types.
|
||||
- It does not fetch weather data, compare prior snapshots, write module
|
||||
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
||||
metadata.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The package receives configured units and timezone from the app layer. Daypart
|
||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||
Configured `location` values are prompt context only; Weather API
|
||||
The app layer passes effective units, timezone, and location context into the
|
||||
module context. `internal/facts` consumes daypart configuration before module
|
||||
builders run. Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||
source only; observation station and timestamp fields remain provenance.
|
||||
|
||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||
subset of discussion fields.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
@@ -49,39 +62,40 @@ None directly.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
||||
owned by `internal/state`.
|
||||
None. `internal/app` collects module outputs into a `module.Snapshot`, and
|
||||
`internal/state` persists that snapshot.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Builders either return a complete briefing package or an error.
|
||||
None. Builders either emit a module output, omit optional unavailable data, or
|
||||
return an error for invalid required inputs.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily briefing construction requires a Daily report definition and derived
|
||||
daily summary.
|
||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||
one derived summary.
|
||||
- Weekend briefing construction requires a Weekend report definition and at
|
||||
least one derived summary.
|
||||
- Storm briefing construction requires a Storm Report definition and forecast
|
||||
bundle.
|
||||
- Save failures include path and operation context.
|
||||
- Required derived modules return errors when their dependent facts are not
|
||||
available.
|
||||
- Module registry construction rejects duplicate module IDs and duplicate
|
||||
stanza names.
|
||||
- Composition validation rejects unknown modules, duplicate modules,
|
||||
incompatible report/module combinations, duplicate stanza names, and invalid
|
||||
option shapes.
|
||||
- Source-oriented module builders omit missing optional current conditions,
|
||||
forecast discussion, and weather story stanzas.
|
||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||
source data.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/briefing/daily_test.go`
|
||||
- `internal/briefing/three_day_test.go`
|
||||
- `internal/briefing/weekend_test.go`
|
||||
- `internal/briefing/storm_test.go`
|
||||
- `internal/briefing/base_modules_test.go`
|
||||
- `internal/briefing/derived_modules_test.go`
|
||||
- `internal/briefing/modules_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Briefings contain structured weather facts and source context.
|
||||
- Module outputs contain structured weather facts and source context.
|
||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||
provenance, source hashes, source warnings, and configured prompt location.
|
||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||
boundary.
|
||||
- Prompt input packaging and Scriptorium execution remain outside this package.
|
||||
|
||||
@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/changes` compares current and prior briefing packages and emits
|
||||
`internal/changes` compares current and prior module snapshots and emits
|
||||
compact change records for prompt input data packages.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior briefing package
|
||||
- current briefing package
|
||||
- prior module snapshot
|
||||
- current module snapshot
|
||||
- comparison thresholds from configuration
|
||||
|
||||
Outputs:
|
||||
@@ -22,7 +22,7 @@ Outputs:
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package compares structured briefing data only.
|
||||
- This package compares structured module snapshot data only.
|
||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||
invoke Scriptorium, or compare generated report text.
|
||||
|
||||
@@ -41,7 +41,7 @@ None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None directly. The app loads prior briefing snapshots through `internal/state`
|
||||
None directly. The app loads prior module snapshots through `internal/state`
|
||||
before calling comparison functions.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
@@ -51,11 +51,12 @@ empty Recent Changes list without calling a comparison function.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily comparison requires both inputs to contain Daily briefing content.
|
||||
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
||||
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
||||
- Storm Report currently has no comparison implementation, so the app leaves
|
||||
Recent Changes empty for Storm reports.
|
||||
- Daily comparison requires `derived_daily_summary` and
|
||||
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
|
||||
`precip_timing` when present.
|
||||
- 3-Day comparison requires `derived_daypart_summaries`.
|
||||
- Weekend comparison requires `derived_daypart_summaries`.
|
||||
- Storm Report comparison returns no changes.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
123
docs/internal/distributor-adapter.md
Normal file
123
docs/internal/distributor-adapter.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Distributor Adapter Internals
|
||||
|
||||
This document describes the distributor upload adapter in
|
||||
`internal/adapters/distributor`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The adapter submits generated weatherreporter Markdown reports to a configured
|
||||
distributor HTTP upload endpoint. It isolates distributor package types,
|
||||
token-env lookup, upload client construction, source-bundle file mapping,
|
||||
timeout handling, status polling, and upload error wrapping from app
|
||||
orchestration.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- distributor endpoint URL
|
||||
- token environment variable name
|
||||
- upload timeout
|
||||
- pipeline ID
|
||||
- bundle ID
|
||||
- idempotency key
|
||||
- source Markdown report path and bundle-relative path mappings
|
||||
- bundle created timestamp
|
||||
- context for cancellation
|
||||
|
||||
Outputs:
|
||||
|
||||
- accepted distributor run ID
|
||||
- accepted distributor upload status
|
||||
- distributor run status, status polling error, and raw run report JSON when available
|
||||
- weatherreporter-owned idempotency conflict error when applicable
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/adapters/distributor` is the only weatherreporter package that imports
|
||||
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
|
||||
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
|
||||
|
||||
The app layer passes weatherreporter-owned request values to the adapter. The
|
||||
adapter does not choose report types, render templates, select output copies,
|
||||
configure destinations, wait for downstream publication, transform Markdown, or
|
||||
persist notification state.
|
||||
|
||||
Full upstream distributor package and HTTP contract details stay under
|
||||
`docs/integrations/distributor/`.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The adapter is built from `notify.distributor` config:
|
||||
|
||||
- `endpoint`
|
||||
- `token_env`
|
||||
- `timeout`
|
||||
|
||||
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
|
||||
from:
|
||||
|
||||
- `pipeline_id_template`
|
||||
- `bundle_id_template`
|
||||
- `idempotency_key_template`
|
||||
- `report_path_templates`
|
||||
|
||||
The token value is read from the environment variable named by `token_env`
|
||||
after config loading and `secrets.directory` processing.
|
||||
|
||||
## Upload Behavior
|
||||
|
||||
The adapter calls distributor `UploadFiles` with one or more file mappings:
|
||||
|
||||
- pipeline ID: the rendered distributor workflow selector
|
||||
- source path: the managed Markdown report path selected by app orchestration
|
||||
- bundle paths: rendered bundle-relative report paths
|
||||
- created: the report generation timestamp
|
||||
|
||||
The adapter creates a distributor upload client with the configured endpoint,
|
||||
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
||||
with the configured timeout when the timeout is greater than zero.
|
||||
|
||||
After upload acceptance, the adapter polls distributor `Status` for the accepted
|
||||
run ID until the run reaches `succeeded` or `failed`, or until the configured
|
||||
timeout expires. It returns the latest status, error text, and raw report JSON in
|
||||
weatherreporter-owned types so app orchestration can persist them in the
|
||||
notification debug artifact. Status lookup failures or timeout before a terminal
|
||||
state are kept as debug status errors on an otherwise accepted upload. A
|
||||
terminal distributor run status of `failed` is returned as a notification failure
|
||||
with the status report preserved.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
The adapter validates required endpoint, token env name, token value, pipeline
|
||||
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
|
||||
upload client inputs before uploading.
|
||||
|
||||
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
|
||||
source paths, and bundle paths context. Token values are redacted from adapter
|
||||
errors.
|
||||
|
||||
Distributor idempotency conflicts are exposed as a weatherreporter-owned
|
||||
`IdempotencyConflictError`, so callers do not depend on upstream distributor
|
||||
types.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/distributor/client_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
Adapter tests use a fake upload client factory and do not require a live
|
||||
distributor service.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Distributor package types do not leak outside the adapter.
|
||||
- Only the managed Markdown report is uploaded.
|
||||
- The adapter never scans the workspace.
|
||||
- Token values are not included in errors, CLI output, metadata, docs, or
|
||||
examples.
|
||||
- Destination routing and Markdown-to-HTML transformation belong to
|
||||
distributor, not weatherreporter.
|
||||
72
docs/internal/facts.md
Normal file
72
docs/internal/facts.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Fact Contracts Internals
|
||||
|
||||
This document describes the fact contract boundary.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/facts` separates normalized upstream facts collected for a report run
|
||||
from conservative report-scoped facts derived from them. The package gives app
|
||||
orchestration one place to build reusable facts before module execution.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- `weatherdata.Bundle` from the Weather API adapter
|
||||
- resolved report definition and valid period
|
||||
- report timezone
|
||||
- configured daypart definitions
|
||||
|
||||
Outputs:
|
||||
|
||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||
provenance and warnings
|
||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||
daily summaries, daypart summaries, and Storm Report window summary
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
report run.
|
||||
- It does not fetch upstream data, build prompt wording, compare prior
|
||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `dayparts[].name`
|
||||
- `dayparts[].start`
|
||||
- `dayparts[].end`
|
||||
- `weather_api.timezone`
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly. Collected facts are built from `weatherdata.Bundle`.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None. Source provenance and warnings remain data fields for downstream metadata
|
||||
and inspection.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid or missing report valid periods return an error.
|
||||
- Invalid timezone names return an error.
|
||||
- Missing required hourly forecast data returns the underlying forecast
|
||||
derivation error for reports that require daily summaries.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/facts/facts_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Collected facts are built once from a fetched bundle.
|
||||
- Derived facts are scoped to one resolved report.
|
||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||
package.
|
||||
@@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/forecast` converts normalized bundle data into daily and period
|
||||
summaries used by briefing builders.
|
||||
`internal/forecast` converts normalized weather data into daily and period
|
||||
summaries used by fact builders and module builders.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- `forecast.Bundle`
|
||||
- `weatherdata.Bundle`
|
||||
- local date or resolved report period
|
||||
- timezone
|
||||
- configured daypart definitions
|
||||
@@ -43,12 +43,12 @@ configuration.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly. Forecast data arrives through `forecast.Bundle`.
|
||||
None directly. Forecast data arrives through `weatherdata.Bundle`.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None. Source warnings and provenance from the bundle are carried into summaries
|
||||
for later metadata and briefing output.
|
||||
for later metadata and module output.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
|
||||
102
docs/internal/module.md
Normal file
102
docs/internal/module.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Module Contract Internals
|
||||
|
||||
This document describes the module contract in `internal/module`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/module` defines the shared identifiers and data envelopes used for
|
||||
prompt-facing modules. Report definitions use module IDs for composition,
|
||||
module builders produce outputs with stanza names, prompt input packages consume
|
||||
snapshots, and Recent Changes compares snapshot stanzas.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- ordered `module.ConfigItem` values from report definitions or config
|
||||
overrides
|
||||
- `module.Output` values produced by module builders
|
||||
|
||||
Outputs:
|
||||
|
||||
- stable `module.ID` constants
|
||||
- typed option structs for registered modules
|
||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||
- typed stanza lookup through `module.StanzaValue`
|
||||
|
||||
## Registered Module IDs
|
||||
|
||||
The registry recognizes these IDs:
|
||||
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `hourly_table`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `area_forecast_discussion`
|
||||
- `weather_story`
|
||||
- `forecast_delta`
|
||||
- `outdoor_windows`
|
||||
- `tomorrow_planning`
|
||||
- `weekend_planning`
|
||||
- `storm_window_summary`
|
||||
|
||||
Modules with builders emit stanzas into module snapshots. Registered modules
|
||||
without builders are valid composition entries but do not emit snapshot stanzas.
|
||||
That keeps report composition declarations centralized while limiting prompt
|
||||
packages to data the application builds.
|
||||
|
||||
## Options
|
||||
|
||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||
|
||||
```yaml
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
```
|
||||
|
||||
An omitted or empty `sections` list includes all available discussion sections.
|
||||
Invalid option shapes fail during config normalization or composition
|
||||
validation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns module identifiers, config item envelopes, output
|
||||
envelopes, snapshot validation, and typed stanza lookup.
|
||||
- It does not define report IDs, execute builders, fetch weather data, derive
|
||||
forecast facts, write state, or invoke Scriptorium.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||
validation rejects missing schema version, missing module IDs, missing stanza
|
||||
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||
output order.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Snapshot construction fails for duplicate module outputs or duplicate stanza
|
||||
names.
|
||||
- Typed stanza lookup returns `found=false` for missing stanzas.
|
||||
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/module/module_test.go`
|
||||
- `internal/briefing/modules_test.go`
|
||||
- `internal/report/period_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- `internal/module` does not import `internal/report`.
|
||||
- Module IDs are stable strings.
|
||||
- Each emitted module output has exactly one stanza name and one typed value.
|
||||
- Snapshot output order is caller-owned and preserved.
|
||||
@@ -1,40 +1,64 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
This document describes prompt input data package construction.
|
||||
This document describes YAML prompt data package construction in
|
||||
`internal/promptinput`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/promptinput` converts a structured briefing package and optional
|
||||
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||
Recent Changes, and source warnings into the `data_package` file passed to
|
||||
Scriptorium.
|
||||
|
||||
The persisted data package is YAML with schema version
|
||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
||||
used for inspection and comparison.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- `briefing.Package`
|
||||
- report metadata from app/state orchestration
|
||||
- `module.Snapshot`
|
||||
- optional `[]changes.Change`
|
||||
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||
briefing content, Recent Changes, and source warnings. Briefing content
|
||||
includes configured location context, current conditions when available,
|
||||
structured weather story context when available, discussion key messages, and
|
||||
short/long-term AFD narratives when the Weather API provides them.
|
||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||
`YYYY-MM-DD` in the effective report timezone.
|
||||
- optional JSON file written by `promptinput.Save`
|
||||
- `promptinput.Package` with schema version, RunID, report metadata, named
|
||||
module stanzas, Recent Changes, and source warnings
|
||||
- YAML bytes from `promptinput.MarshalYAML`
|
||||
- YAML file written atomically by `promptinput.Save`
|
||||
|
||||
The YAML shape includes:
|
||||
|
||||
```yaml
|
||||
schema_version: weatherreporter.data_package.v2
|
||||
run_id: <run_id>
|
||||
report:
|
||||
id: <report_id>
|
||||
prompt_id: <prompt_id>
|
||||
briefing:
|
||||
metadata: {}
|
||||
current_conditions: {}
|
||||
recent_changes:
|
||||
items: []
|
||||
```
|
||||
|
||||
The `briefing` mapping contains named module stanzas. Stanza order follows the
|
||||
module snapshot output order.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns the prompt input schema and validation.
|
||||
- It does not fetch weather data, derive forecast summaries, find prior
|
||||
snapshots, compare changes, or invoke Scriptorium.
|
||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||
validation.
|
||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
||||
Scriptorium.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
None directly. Config-derived values, including timezone and prompt location
|
||||
context, are already present in briefing metadata before this package runs.
|
||||
None directly. Config-derived values such as timezone, units, and prompt
|
||||
location are already present in report metadata and module stanzas before this
|
||||
package runs.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
@@ -42,7 +66,7 @@ None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by
|
||||
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
|
||||
`internal/state`.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
@@ -51,9 +75,10 @@ None. Recent Changes is always present as an `items` list and may be empty.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Validation fails before render preflight when required top-level or briefing
|
||||
metadata fields are missing or inconsistent, or when no report content is
|
||||
present. Save failures include filesystem operation and path context.
|
||||
Validation fails before render preflight when required top-level fields are
|
||||
missing or inconsistent, when the valid period is invalid, or when no module
|
||||
stanzas are present. Save failures include filesystem operation and path
|
||||
context.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -64,6 +89,7 @@ Inspect:
|
||||
|
||||
## Invariants
|
||||
|
||||
- Scriptorium receives structured `data_package` JSON.
|
||||
- Briefing metadata and top-level report metadata must agree.
|
||||
- Recent Changes are not inferred from rendered report text.
|
||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||
- Module stanza order is deterministic for generated snapshots.
|
||||
- Recent Changes are provided by `internal/changes`; this package does not
|
||||
infer changes from rendered report text.
|
||||
|
||||
@@ -7,8 +7,8 @@ membership, output naming, artifact grouping, and comparison declarations in
|
||||
## Purpose
|
||||
|
||||
`internal/report` is the canonical source for report definitions. App, state,
|
||||
briefing, and CLI wiring consume resolved definitions instead of owning report
|
||||
identity policy themselves.
|
||||
module building, and CLI wiring consume resolved definitions instead of owning
|
||||
report identity policy themselves.
|
||||
|
||||
## Definition Fields
|
||||
|
||||
@@ -23,8 +23,9 @@ Each report definition declares:
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
- default ordered module composition
|
||||
|
||||
## Implemented Reports
|
||||
## Reports
|
||||
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -34,7 +35,7 @@ Each report definition declares:
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
|
||||
All implemented report definitions are eligible for generation.
|
||||
All report definitions are eligible for generation.
|
||||
|
||||
## Valid Periods
|
||||
|
||||
@@ -54,8 +55,8 @@ must be after start time.
|
||||
## Boundaries
|
||||
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||
flags, or invoke Scriptorium.
|
||||
weather data, build module values, compare snapshot contents, write state,
|
||||
parse CLI flags, or invoke Scriptorium.
|
||||
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
@@ -70,6 +71,7 @@ output path copying uses batch output names from report definitions.
|
||||
- State paths use `ArtifactGroup`.
|
||||
- Batch output copies use `BatchOutputName`.
|
||||
- Generation checks `Generated`.
|
||||
- Module composition defaults use `Modules`.
|
||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||
- RunIDs include the resolved report ID.
|
||||
|
||||
@@ -93,5 +95,5 @@ Inspect:
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
comparison compatibility, and comparison strategy are declared by report
|
||||
definition.
|
||||
default module composition, comparison compatibility, and comparison strategy
|
||||
are declared by report definition.
|
||||
|
||||
@@ -15,7 +15,7 @@ from app and domain packages.
|
||||
Inputs:
|
||||
|
||||
- prompt ID
|
||||
- prompt input data package path
|
||||
- YAML prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
@@ -96,5 +96,6 @@ Inspect:
|
||||
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- The file at the data package path is YAML produced by `internal/promptinput`.
|
||||
- Render and run preserve command-specific result structs.
|
||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||
|
||||
@@ -13,7 +13,7 @@ Inputs:
|
||||
|
||||
- workspace configuration
|
||||
- resolved report definition and valid period
|
||||
- briefing package
|
||||
- module snapshot
|
||||
- prompt input data package
|
||||
- preflight artifact
|
||||
- rendered report path preparation request
|
||||
@@ -21,20 +21,20 @@ Inputs:
|
||||
|
||||
Outputs:
|
||||
|
||||
- briefing snapshot JSON path
|
||||
- prompt input data package JSON path
|
||||
- module snapshot JSON path
|
||||
- prompt input data package YAML path
|
||||
- render preflight JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- prior comparable snapshot metadata
|
||||
- loaded briefing or data package
|
||||
- loaded module snapshot or data package
|
||||
- recent report records for inspection
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||
data, derive forecasts, build prompt input content, compare briefing contents,
|
||||
data, derive forecasts, build prompt input content, compare module contents,
|
||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||
|
||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||
@@ -47,6 +47,7 @@ converts adapter render results into that shape before saving.
|
||||
- `workspace.reports_dir`
|
||||
- `workspace.data_packages_dir`
|
||||
- `workspace.preflight_dir`
|
||||
- `workspace.notifications_dir`
|
||||
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
@@ -54,20 +55,22 @@ Workspace subdirectories must be relative paths that stay under
|
||||
## Managed Layout
|
||||
|
||||
Paths are derived from the resolved report definition's artifact group, the
|
||||
valid-period start date for JSON artifacts, and the RunID.
|
||||
valid-period start date for dated artifacts, and the RunID.
|
||||
|
||||
```text
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
```
|
||||
|
||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||
package, preflight, report paths, and configured prompt location. Report
|
||||
listing walks metadata files under the snapshots directory.
|
||||
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||
package, preflight, report paths, notification path when attempted, and
|
||||
configured prompt location. Report listing walks metadata files under the
|
||||
snapshots directory.
|
||||
|
||||
## Prior Lookup
|
||||
|
||||
@@ -81,18 +84,22 @@ current report definition.
|
||||
date.
|
||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||
window.
|
||||
- Storm Report currently has no prior lookup because explicit event-window
|
||||
comparison is not searched by the filesystem store.
|
||||
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||
not searched by the filesystem store.
|
||||
|
||||
## Writes And Inspection
|
||||
|
||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
||||
prepared by creating their parent directory; Scriptorium writes the report body
|
||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
||||
Distributor notification debug artifacts are written atomically when
|
||||
notification is attempted and include rendered distributor pipeline ID, bundle
|
||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
||||
redacted errors.
|
||||
|
||||
Inspection helpers read existing metadata, briefing, and data package files.
|
||||
Missing metadata directories return no inspection records or no prior snapshot
|
||||
rather than creating state.
|
||||
Inspection helpers read existing metadata, module snapshot, and data package
|
||||
files. Missing metadata directories return no inspection records or no prior
|
||||
snapshot rather than creating state.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Weather Data Internals
|
||||
|
||||
This document describes Weather API ingestion into `forecast.Bundle`.
|
||||
This document describes Weather API ingestion into `weatherdata.Bundle`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and briefing builders. Briefing builders expose normalized current
|
||||
derivation and module builders. Module builders expose normalized current
|
||||
conditions and weather story context when those sources are available.
|
||||
|
||||
## Inputs And Outputs
|
||||
@@ -19,7 +19,7 @@ Inputs:
|
||||
|
||||
Outputs:
|
||||
|
||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, latest weather story, source
|
||||
records, and source warnings
|
||||
- stub source record for the daily forecast source slot
|
||||
@@ -29,7 +29,7 @@ Outputs:
|
||||
|
||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||
decoding into internal bundle types.
|
||||
- It does not derive dayparts, resolve report periods, build briefings, compare
|
||||
- It does not derive dayparts, resolve report periods, build module values, compare
|
||||
snapshots, write report state, or invoke Scriptorium.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Weatherreporter Operations
|
||||
|
||||
This guide covers normal operation, generated artifacts, inspection, recovery,
|
||||
and current operational caveats. For symptom-specific diagnosis, see
|
||||
and operational caveats. For symptom-specific diagnosis, see
|
||||
[Troubleshooting](troubleshooting.md).
|
||||
|
||||
## Normal Workflow
|
||||
|
||||
Implemented generation commands:
|
||||
Generation commands:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
@@ -17,11 +17,15 @@ weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||
briefing, builds a prompt input data package, runs `scriptorium render`, runs
|
||||
`scriptorium run`, and writes managed artifacts under the configured workspace.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report.
|
||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
||||
the configured workspace.
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
||||
is not used as the distributor upload source.
|
||||
|
||||
Implemented batch commands:
|
||||
Batch commands:
|
||||
|
||||
```text
|
||||
weatherreporter run morning
|
||||
@@ -32,9 +36,12 @@ weatherreporter run evening
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. `--out-dir PATH` writes extra Markdown copies
|
||||
using report default filenames such as `daily.md`, `three-day.md`,
|
||||
`weekend.md`, and `tomorrow.md`.
|
||||
nonzero when any report failed. When notification is configured, the summary and
|
||||
status lines include notification status, accepted distributor run ID, or
|
||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||
extra Markdown copies using report default filenames such as `daily.md`,
|
||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||
distributor upload sources.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
@@ -45,33 +52,33 @@ workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
<run_id>.data_package.yaml
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
<run_id>.data_package.yaml
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
<run_id>.data_package.yaml
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
<run_id>.data_package.yaml
|
||||
preflight/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
@@ -85,6 +92,19 @@ workspace/
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
notifications/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
@@ -112,13 +132,61 @@ Each generated report writes metadata that links:
|
||||
- RunID, report ID, variant, and prompt ID
|
||||
- generation time, timezone, and valid period
|
||||
- source location, source hashes, and source warnings
|
||||
- briefing snapshot path
|
||||
- module snapshot path
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- managed Markdown report path
|
||||
- distributor notification debug artifact path, when notification is attempted
|
||||
|
||||
Batch summaries include report status, error text when applicable, valid
|
||||
period, and known artifact paths for each attempted report.
|
||||
Batch summaries include report status, error text when applicable, notification
|
||||
outcome when attempted, valid period, and known artifact paths for each
|
||||
attempted report. Notification fields are `notificationStatus`,
|
||||
`notificationRunId`, and `notificationError`.
|
||||
|
||||
## Distributor Notification
|
||||
|
||||
Distributor notification is configured with `notify.distributor` and is
|
||||
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
||||
report path recorded in the report result and metadata. That single source file
|
||||
can be mapped to one or more configured bundle paths. By default, it is mapped
|
||||
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
||||
operator conveniences only.
|
||||
|
||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||
workflow. The default bundle ID is a stable logical source identity derived from
|
||||
producer name, location ID, and report ID:
|
||||
|
||||
```text
|
||||
weatherreporter.{location_id}.{report_id}
|
||||
```
|
||||
|
||||
The default idempotency key appends RunID to the rendered bundle ID so each
|
||||
report generation has a distinct retry identity. The default bundle path uses
|
||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||
|
||||
Notification happens after final metadata save. Weather API, module snapshot,
|
||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
||||
not trigger notification. A notification failure fails that report.
|
||||
In a batch, other reports continue, the failed report includes notification
|
||||
fields in the JSON summary, and the batch returns nonzero.
|
||||
|
||||
Each notification attempt writes a debug artifact under `notifications/`. The
|
||||
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
|
||||
source path, bundle-relative paths, bundle created timestamp, accepted upload
|
||||
response, and the latest distributor run status response when available.
|
||||
Weatherreporter polls status until distributor reports `succeeded` or `failed`,
|
||||
or until the configured notification timeout expires. The run status includes
|
||||
the distributor status, error text, and raw run report JSON, which can show
|
||||
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or
|
||||
`failed`. Token values are not written.
|
||||
|
||||
Weatherreporter is responsible for selecting the managed Markdown report,
|
||||
constructing a source bundle, and submitting it to the configured distributor
|
||||
HTTP endpoint. Distributor remains responsible for destination routing,
|
||||
publication, and any downstream Markdown-to-HTML transformation. Distributor
|
||||
leaves destination files alone when they are not tracked by a newly uploaded
|
||||
bundle, so existing uploaded dated report paths can remain available.
|
||||
|
||||
## Inspection
|
||||
|
||||
@@ -128,30 +196,30 @@ They do not fetch weather data or run `scriptorium`.
|
||||
```text
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect modules RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
```
|
||||
|
||||
Use `inspect reports` to find recent RunIDs and artifact paths. Use
|
||||
Use `inspect reports` to find RunIDs and artifact paths. Use
|
||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||
`inspect briefing` and `inspect data-package` to review the exact structured
|
||||
inputs used for rendering. Use `inspect prior` to see the prior comparable
|
||||
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||
`inspect sources` to review source provenance and warnings without dumping full
|
||||
weather payloads.
|
||||
`inspect modules` to review the persisted ordered module snapshot, and
|
||||
`inspect data-package` to review the structured prompt package used for
|
||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
||||
source provenance and warnings without dumping full weather payloads.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
Recent Changes are computed from structured briefing snapshots, not rendered
|
||||
Markdown text.
|
||||
Recent Changes are computed from structured module snapshots, not rendered
|
||||
Markdown or YAML text.
|
||||
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report currently
|
||||
leaves Recent Changes empty.
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
||||
Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
`recentChanges.items` is empty.
|
||||
@@ -164,6 +232,8 @@ A failed generation run may still leave useful artifacts:
|
||||
preflight JSON and metadata are written for inspection.
|
||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||
and metadata remain available.
|
||||
- If distributor notification fails, report artifacts and final metadata remain
|
||||
available, but the report or batch command returns nonzero.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
artifact paths for each failed report.
|
||||
|
||||
@@ -172,7 +242,7 @@ For a bad report, start with:
|
||||
```text
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect modules RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
```
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves.
|
||||
|
||||
## weatherreporter
|
||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports.
|
||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
|
||||
|
||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.
|
||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
|
||||
|
||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||
|
||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||
|
||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
||||
|
||||
`distributor` is also an external adapter. Upload behavior must be isolated
|
||||
under `internal/adapters/distributor`, dependency types from the distributor
|
||||
module must not leak outside that adapter, and the selected upload source must
|
||||
be the managed Markdown report rather than optional output copies or broad
|
||||
workspace scans.
|
||||
|
||||
## Project Shape
|
||||
|
||||
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
|
||||
@@ -54,7 +60,7 @@ Configuration precedence is:
|
||||
|
||||
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
|
||||
|
||||
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets.
|
||||
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
|
||||
|
||||
## Adapters and External Integrations
|
||||
|
||||
|
||||
@@ -13,13 +13,17 @@ Developers and LLM coding agents should use it with
|
||||
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
||||
validation.
|
||||
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
||||
- `internal/adapters/distributor`: Distributor upload adapter.
|
||||
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||
- `internal/forecast`: normalized bundle types and deterministic forecast
|
||||
derivation.
|
||||
- `internal/weatherdata`: normalized weather source facts, source metadata, and
|
||||
source warnings.
|
||||
- `internal/forecast`: deterministic forecast derivation.
|
||||
- `internal/facts`: collected and derived report fact contracts.
|
||||
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
|
||||
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||
and comparison declarations.
|
||||
- `internal/briefing`: report-specific briefing package builders.
|
||||
- `internal/briefing`: prompt-facing module value builders and module registry.
|
||||
- `internal/changes`: structured Recent Changes comparison.
|
||||
- `internal/promptinput`: Scriptorium `data_package` construction and
|
||||
validation.
|
||||
@@ -45,7 +49,7 @@ Useful focused checks:
|
||||
```bash
|
||||
go test ./internal/cli ./internal/config
|
||||
go test ./internal/app ./internal/state
|
||||
go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
||||
```
|
||||
|
||||
@@ -64,6 +68,8 @@ Run `gofmt -w` on changed Go files before committing.
|
||||
- Use atomic writes for durable JSON artifacts where practical.
|
||||
- Keep report selection and prompt IDs centralized in `internal/report`.
|
||||
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
|
||||
- Keep distributor package types and upload-client construction inside
|
||||
`internal/adapters/distributor`.
|
||||
- Keep Weather API transport and envelope handling inside
|
||||
`internal/adapters/weatherapi`.
|
||||
|
||||
@@ -72,8 +78,10 @@ Run `gofmt -w` on changed Go files before committing.
|
||||
Prefer the Go standard library. Add dependencies only when they materially
|
||||
improve correctness, interoperability, security, or maintainability.
|
||||
|
||||
Current external dependency:
|
||||
Current external dependencies:
|
||||
|
||||
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
|
||||
construction and HTTP upload client behavior.
|
||||
- `gopkg.in/yaml.v3` for YAML configuration parsing.
|
||||
|
||||
When adding a dependency:
|
||||
@@ -145,12 +153,14 @@ When an external contract changes, update the matching file under
|
||||
|
||||
## Tests
|
||||
|
||||
Core tests must not require live Weather API or Scriptorium services.
|
||||
Core tests must not require live Weather API, Scriptorium, or distributor
|
||||
services.
|
||||
|
||||
Preferred test patterns:
|
||||
|
||||
- fake command runners for subprocess behavior;
|
||||
- `httptest.Server` for Weather API behavior;
|
||||
- fake distributor upload clients for notification behavior;
|
||||
- filesystem temp directories for state behavior;
|
||||
- deterministic clocks for report periods and RunIDs;
|
||||
- table tests for config validation, CLI parsing, period resolution, and
|
||||
@@ -187,7 +197,8 @@ Update:
|
||||
behavior;
|
||||
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
|
||||
- `docs/internal/` for component contracts and invariants;
|
||||
- `docs/integrations/` for external Weather API or Scriptorium contract changes;
|
||||
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
|
||||
contract changes;
|
||||
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||
|
||||
Non-roadmap docs must describe implemented behavior only.
|
||||
|
||||
34
docs/roadmap/distributor.md
Normal file
34
docs/roadmap/distributor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Distributor Roadmap
|
||||
|
||||
Current distributor notification behavior is documented outside the roadmap:
|
||||
|
||||
- [Configuration reference](../config.md)
|
||||
- [Operations guide](../operations.md)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
- [Distributor adapter internals](../internal/distributor-adapter.md)
|
||||
|
||||
This file tracks future distributor-related work only.
|
||||
|
||||
## Deferred Enhancements
|
||||
|
||||
- Add a supported warning-only notification policy.
|
||||
- Include selected non-report artifacts in uploaded bundles.
|
||||
- Poll distributor run status after upload acceptance.
|
||||
- Persist upload retry state across process restarts.
|
||||
- Add explicit CLI controls for distributor behavior.
|
||||
|
||||
## Non-Goals Without A Separate Design
|
||||
|
||||
- Do not make distributor scan the weatherreporter workspace.
|
||||
- Do not move destination routing into weatherreporter.
|
||||
- Do not move Markdown-to-HTML transformation into weatherreporter.
|
||||
- Do not store raw bearer tokens in configuration files.
|
||||
|
||||
## Required Constraints For Future Work
|
||||
|
||||
- Distributor package types stay inside `internal/adapters/distributor`.
|
||||
- Weatherreporter submits explicit source bundles built from generated files.
|
||||
- Optional `--out` and `--out-dir` copies remain operator conveniences, not
|
||||
canonical upload sources.
|
||||
- Secret values stay out of errors, logs, CLI output, metadata, examples, and
|
||||
documentation.
|
||||
@@ -49,6 +49,26 @@ These ideas are not current behavior:
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
|
||||
## Deferred: Distributor Notification Enhancements
|
||||
|
||||
Distributor notification currently uploads one managed Markdown report per
|
||||
successful generated report through the configured HTTP upload endpoint.
|
||||
|
||||
These enhancements are not current behavior:
|
||||
|
||||
- `failure_policy: warn`;
|
||||
- uploading metadata, briefing snapshots, data packages, or preflight artifacts;
|
||||
- polling distributor status after upload acceptance;
|
||||
- durable upload retry queues;
|
||||
- distributor-specific CLI flags;
|
||||
- making distributor scan the weatherreporter workspace;
|
||||
- handling destination routing, Markdown-to-HTML transformation, public URLs,
|
||||
or nginx layout inside weatherreporter.
|
||||
|
||||
Any distributor enhancement should preserve the existing adapter boundary:
|
||||
weatherreporter selects explicit generated files and submits source bundles,
|
||||
while distributor owns destination routing and publication behavior.
|
||||
|
||||
## Deferred: Cleanup Refactors
|
||||
|
||||
The initial cleanup pass intentionally left these refactors out because the
|
||||
|
||||
791
docs/roadmap/implementation.md
Normal file
791
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,791 @@
|
||||
# Modular Data Package Implementation Roadmap
|
||||
|
||||
This roadmap is a staged implementation plan for
|
||||
[`docs/roadmap/modules.md`](modules.md). It is future-work planning only. The
|
||||
target audience is an LLM coding agent implementing each stage in order.
|
||||
|
||||
## Purpose
|
||||
|
||||
Implement a pre-release hard cutover from report-shaped briefing packages to a
|
||||
module-oriented prompt package architecture:
|
||||
|
||||
```text
|
||||
CollectedFacts -> DerivedFacts -> ModuleOutput
|
||||
```
|
||||
|
||||
The implementation should produce YAML `data_package` artifacts with named
|
||||
stanzas for Scriptorium prompts, persist JSON module snapshots for inspection
|
||||
and Recent Changes, and make report composition configurable through ordered
|
||||
module IDs plus typed module options.
|
||||
|
||||
## Source Roadmap
|
||||
|
||||
[`docs/roadmap/modules.md`](modules.md) is authoritative for the conceptual
|
||||
policy, user intent, target prompt shape, boundaries, and acceptance criteria
|
||||
for this refactor. This document is authoritative for implementation order,
|
||||
stage scope, file/package guidance, and validation commands.
|
||||
|
||||
If this implementation plan appears to conflict with `modules.md`, stop and
|
||||
reconcile the roadmap before changing code. Do not infer a different policy
|
||||
from stage sequencing.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Do a clean break. Do not preserve old report-shaped briefing JSON as a
|
||||
compatibility layer.
|
||||
- Introduce `internal/weatherdata` for normalized collected source types.
|
||||
- Keep `internal/forecast` for forecast-specific derivation algorithms such as
|
||||
period selection, daily summaries, daypart grouping, and precipitation
|
||||
timing.
|
||||
- Introduce an internal fact contract for `CollectedFacts` and `DerivedFacts`.
|
||||
- Introduce a narrow module contract for module IDs, typed options, outputs,
|
||||
and snapshots.
|
||||
- Persist both artifacts:
|
||||
- JSON module snapshots for state, inspection, and Recent Changes;
|
||||
- YAML prompt data packages passed to Scriptorium as `data_package`.
|
||||
- Replace `weatherreporter inspect briefing` with
|
||||
`weatherreporter inspect modules`.
|
||||
- Support typed module options from the first configurable composition pass.
|
||||
- Use named YAML stanzas, not a generic array of module objects.
|
||||
- Omit QPF fields until a real upstream QPF source is represented in
|
||||
`CollectedFacts`.
|
||||
- Keep public generate/run command names, report IDs, prompt IDs, RunID format,
|
||||
managed Markdown report paths, and distributor upload source stable.
|
||||
- Do not introduce plugins, dynamic loading, generic workflow engines, or
|
||||
module-owned upstream fetching.
|
||||
|
||||
## Target Packages
|
||||
|
||||
Implementation should converge on this package ownership:
|
||||
|
||||
- `internal/weatherdata`: normalized collected source facts, source metadata,
|
||||
source warnings, and broad weather data types.
|
||||
- `internal/forecast`: deterministic forecast-specific algorithms over
|
||||
`weatherdata` types.
|
||||
- `internal/facts`: `CollectedFacts`, `DerivedFacts`, and their builders.
|
||||
- `internal/module`: stable module IDs, module output envelope, module snapshot
|
||||
shape, module config item shape, and shared option/output contracts that must
|
||||
be imported by both `internal/report` and `internal/briefing`.
|
||||
- `internal/briefing`: module registry and module builders.
|
||||
- `internal/report`: report definitions, valid periods, output identity,
|
||||
comparison strategy, and default module composition.
|
||||
- `internal/config`: YAML config structs, defaults, loading, and validation for
|
||||
module composition overrides.
|
||||
- `internal/promptinput`: YAML prompt package assembly, validation, and save
|
||||
behavior.
|
||||
- `internal/changes`: structured comparison over module snapshots.
|
||||
- `internal/state`: module snapshot paths, YAML data package paths, metadata
|
||||
links, and inspection loads.
|
||||
- `internal/app`: orchestration only.
|
||||
|
||||
Avoid import cycles. In particular, `internal/report` may import
|
||||
`internal/module` for module IDs, but `internal/module` must not import
|
||||
`internal/report`.
|
||||
|
||||
## Target Artifacts
|
||||
|
||||
Use explicit schema versions:
|
||||
|
||||
- Module snapshot JSON: `weatherreporter.modules.v1`
|
||||
- YAML prompt data package: `weatherreporter.data_package.v2`
|
||||
|
||||
Target workspace paths:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
|
||||
snapshots/<artifact_group>/<valid_date>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
|
||||
```
|
||||
|
||||
Metadata should link the module snapshot and YAML data package paths. Existing
|
||||
metadata links for preflight, rendered report, source warnings, source hashes,
|
||||
and distributor notification artifacts should remain.
|
||||
|
||||
## Target Module Defaults
|
||||
|
||||
Initial implemented default module IDs should cover current behavior without
|
||||
QPF-specific fields:
|
||||
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `area_forecast_discussion`
|
||||
- `weather_story`
|
||||
- `forecast_delta`
|
||||
- `outdoor_windows`
|
||||
- `weekend_planning`
|
||||
- `storm_window_summary`
|
||||
|
||||
Default report composition should be declared in `internal/report`:
|
||||
|
||||
- Daily Today:
|
||||
`metadata`, `current_conditions`, `derived_daily_summary`,
|
||||
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
|
||||
`forecast_delta`, `area_forecast_discussion`, `weather_story`,
|
||||
`outdoor_windows`
|
||||
- Daily Tomorrow:
|
||||
same as Daily Today, plus any tomorrow-planning module needed to preserve
|
||||
current tomorrow behavior.
|
||||
- 3-Day:
|
||||
`metadata`, `current_conditions`, `derived_daypart_summaries`,
|
||||
`precip_timing`, `alert_digest`, `forecast_delta`,
|
||||
`area_forecast_discussion`, `weather_story`, `outdoor_windows`
|
||||
- Weekend:
|
||||
`metadata`, `current_conditions`, `derived_daypart_summaries`,
|
||||
`precip_timing`, `alert_digest`, `area_forecast_discussion`,
|
||||
`weather_story`, `outdoor_windows`, `weekend_planning`
|
||||
- Storm:
|
||||
`metadata`, `current_conditions`, `hourly_table`, `precip_timing`,
|
||||
`alert_digest`, `area_forecast_discussion`, `weather_story`,
|
||||
`storm_window_summary`
|
||||
|
||||
If preserving a current report behavior requires a narrower module, add a
|
||||
specific module rather than keeping old report-shaped containers.
|
||||
|
||||
## Stage 1: Weatherdata Package Split
|
||||
|
||||
Goal: separate broad normalized weather data from forecast-specific derivation.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- create `internal/weatherdata`;
|
||||
- update `internal/forecast`;
|
||||
- update `internal/adapters/weatherapi`;
|
||||
- update packages that currently import normalized source types from
|
||||
`internal/forecast`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Move normalized source/domain types out of `internal/forecast` when they are
|
||||
not forecast algorithms:
|
||||
- bundle/source metadata/warnings;
|
||||
- current conditions;
|
||||
- observation run types if present;
|
||||
- alert run and alert overlap source types;
|
||||
- forecast run and forecast period source types;
|
||||
- discussion and discussion section types;
|
||||
- weather story types.
|
||||
- Keep deterministic derivation functions in `internal/forecast`.
|
||||
- Update Weather API adapter return types to use `weatherdata.Bundle`.
|
||||
- Keep JSON field names and Weather API fixture behavior unchanged.
|
||||
- Do not change CLI behavior, artifact paths, or prompt input yet.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Weather API adapter tests pass with `weatherdata` types.
|
||||
- Forecast derivation tests pass using `weatherdata` inputs.
|
||||
- No external adapter dependency types leak into `weatherdata`.
|
||||
- Existing generated report behavior is unchanged at this stage.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/weatherdata ./internal/forecast ./internal/adapters/weatherapi
|
||||
go test ./internal/app ./internal/briefing ./internal/promptinput
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt if kept mechanical.
|
||||
|
||||
## Stage 2: Fact Contracts
|
||||
|
||||
Goal: add explicit `CollectedFacts` and `DerivedFacts` contracts.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- create `internal/facts`;
|
||||
- update `internal/app`;
|
||||
- update `internal/forecast` tests as needed.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Define `CollectedFacts` as normalized upstream facts collected once per
|
||||
report run.
|
||||
- Define `DerivedFacts` as conservative, reusable, report-scoped
|
||||
transformations.
|
||||
- Add builders:
|
||||
- `BuildCollected(bundle *weatherdata.Bundle) CollectedFacts`
|
||||
- `BuildDerived(req BuildDerivedRequest) (DerivedFacts, error)`
|
||||
- `BuildDerivedRequest` should include the resolved report, timezone,
|
||||
configured dayparts, and `CollectedFacts`.
|
||||
- `DerivedFacts` may include:
|
||||
- valid-period hourly periods;
|
||||
- valid-period narrative periods;
|
||||
- alert overlaps;
|
||||
- daily summaries;
|
||||
- daypart summaries where reusable;
|
||||
- precipitation timing if reused by multiple modules.
|
||||
- Do not put prompt wording, prose strings, module-specific ranking, or
|
||||
one-off presentation decisions in `DerivedFacts`.
|
||||
- Keep source provenance and warnings separate from ordinary fact access.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `CollectedFacts` can be built once from a fetched bundle.
|
||||
- `DerivedFacts` can be built for Daily, Tomorrow, 3-Day, Weekend, and Storm.
|
||||
- Derived fact builders have tests for valid-period slicing, daypart grouping,
|
||||
alert overlaps, and missing optional sources.
|
||||
- No module or prompt code exists yet that fetches upstream data.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/facts ./internal/forecast ./internal/app
|
||||
go test ./internal/...
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 3: Module Core Contracts
|
||||
|
||||
Goal: define module IDs, options, outputs, snapshots, and registry mechanics.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- create `internal/module`;
|
||||
- update `internal/report`;
|
||||
- update `internal/briefing`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Define:
|
||||
- `module.ID`;
|
||||
- module ID constants;
|
||||
- `module.ConfigItem`;
|
||||
- `module.Output`;
|
||||
- `module.Snapshot`;
|
||||
- shared schema version constants.
|
||||
- `module.Output` should contain module ID, stanza name, and typed value.
|
||||
- `module.Snapshot` should preserve ordered outputs and support typed stanza
|
||||
lookup for comparison code.
|
||||
- Add duplicate module and duplicate stanza-name validation.
|
||||
- Add typed option structs for initial modules. Empty option structs are fine
|
||||
for modules without options.
|
||||
- Add a module registry in `internal/briefing` that maps module IDs to builder
|
||||
definitions.
|
||||
- Module definitions should declare:
|
||||
- ID;
|
||||
- stanza name;
|
||||
- option type;
|
||||
- default options;
|
||||
- required collected facts;
|
||||
- required derived facts;
|
||||
- supported report IDs or report categories;
|
||||
- missing-data behavior.
|
||||
- Do not execute modules from app orchestration yet unless needed for tests.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Report definitions can refer to `module.ID` without import cycles.
|
||||
- Module registry tests reject unknown modules, duplicate module IDs, duplicate
|
||||
stanza names, incompatible reports, and invalid option shapes.
|
||||
- Module output and snapshot JSON marshal deterministically enough for tests.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/module ./internal/briefing ./internal/report
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 4: Base Modules
|
||||
|
||||
Goal: implement source-oriented modules that mostly pass through normalized or
|
||||
lightly selected facts.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/briefing`;
|
||||
- `internal/module`;
|
||||
- tests under `internal/briefing`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Implement these modules:
|
||||
- `metadata`;
|
||||
- `current_conditions`;
|
||||
- `alert_digest`;
|
||||
- `area_forecast_discussion`;
|
||||
- `weather_story`.
|
||||
- The `metadata` module should expose report metadata, configured location,
|
||||
units, timezone, valid period, source warnings summary, and alert checked
|
||||
status where appropriate.
|
||||
- `area_forecast_discussion` should expose key messages, short-term text, and
|
||||
long-term text when present.
|
||||
- `weather_story` should expose structured story fields when present and omit
|
||||
the stanza when missing/suppressed by missing-source policy.
|
||||
- `alert_digest` should distinguish checked/no-active-alerts from missing alert
|
||||
source data.
|
||||
- Ordinary modules should not expose endpoint, hash, or transport provenance;
|
||||
provenance should remain metadata/source-warning oriented.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Each module has focused tests for available data, missing optional data, and
|
||||
empty output omission.
|
||||
- No module fetches upstream data or reads/writes durable state.
|
||||
- Output field names use YAML-friendly snake_case and unit suffixes where
|
||||
needed.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/briefing ./internal/module ./internal/facts
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 5: Derived Fact Modules
|
||||
|
||||
Goal: implement deterministic modules that package reusable forecast
|
||||
derivations for the LLM.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/forecast`;
|
||||
- `internal/facts`;
|
||||
- `internal/briefing`;
|
||||
- `internal/module`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Implement:
|
||||
- `derived_daily_summary`;
|
||||
- `derived_daypart_summaries`;
|
||||
- `precip_timing`;
|
||||
- `outdoor_windows`;
|
||||
- any tomorrow-planning module needed to preserve Tomorrow output quality.
|
||||
- `derived_daily_summary` should include current implementable fields:
|
||||
- `high_temp_f`;
|
||||
- `low_temp_f`;
|
||||
- `max_pop_percent`;
|
||||
- `max_pop_window`;
|
||||
- `first_precip_hour`;
|
||||
- `last_precip_hour`;
|
||||
- `thunder_mentioned`;
|
||||
- `max_wind_gust_mph`;
|
||||
- `heat_index_max_f` when source data supports it.
|
||||
- Do not implement `measurable_qpf_total_in` or `max_hourly_qpf_in` until QPF
|
||||
exists in `CollectedFacts`.
|
||||
- `derived_daypart_summaries` should expose daypart keyed values using the
|
||||
configured daypart definitions.
|
||||
- Keep broad reusable calculations in `DerivedFacts`; keep prompt-shape
|
||||
packaging inside modules.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Derived modules have fixture coverage across ordinary, dry, rainy, windy,
|
||||
cold/heat, and missing-data scenarios.
|
||||
- QPF fields are absent unless an upstream QPF source exists.
|
||||
- Daily and Tomorrow module outputs contain enough data to replace current
|
||||
report-shaped daily briefing content.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/forecast ./internal/facts ./internal/briefing ./internal/module
|
||||
```
|
||||
|
||||
This stage may be too large for one prompt if all modules are implemented at
|
||||
once. Split into Daily-derived modules first, then outlook/storm derived
|
||||
modules if needed.
|
||||
|
||||
## Stage 6: Report Composition And Config Overrides
|
||||
|
||||
Goal: make report definitions and config the source of module composition.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/report`;
|
||||
- `internal/config`;
|
||||
- `examples/config.yml`;
|
||||
- config tests.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Extend `report.Definition` with default ordered module IDs.
|
||||
- Keep valid-period resolution, prompt IDs, output naming, generated flag, and
|
||||
comparison strategy in `internal/report`.
|
||||
- Add config support:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- current_conditions
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
```
|
||||
|
||||
- Support both string shorthand and object form for module entries.
|
||||
- Normalize config into typed `module.ConfigItem` values.
|
||||
- Decode module options into typed option structs during validation or before
|
||||
module execution.
|
||||
- Reject:
|
||||
- unknown report IDs;
|
||||
- unknown module IDs;
|
||||
- duplicate modules unless explicitly allowed by that module;
|
||||
- duplicate stanza names;
|
||||
- incompatible report/module combinations;
|
||||
- invalid options.
|
||||
- Built-in defaults should work when no report module config is present.
|
||||
- Example config may omit module overrides unless an example is needed.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Defaults reproduce intended module composition for all implemented reports.
|
||||
- A config edit can add/remove an implemented module for a report.
|
||||
- Invalid module config errors are actionable and do not mention raw internal
|
||||
panic/details.
|
||||
- Config examples load.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/report ./internal/config ./internal/briefing
|
||||
go run ./cmd/weatherreporter --help
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 7: Module Snapshot State
|
||||
|
||||
Goal: persist and inspect JSON module snapshots without changing Scriptorium
|
||||
input yet.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/state`;
|
||||
- `internal/app`;
|
||||
- `internal/cli`;
|
||||
- app/state/CLI tests.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add state paths for `<run_id>.modules.json`.
|
||||
- Add save/load methods for module snapshots.
|
||||
- Update metadata to include `ModuleSnapshotPath`.
|
||||
- Add `weatherreporter inspect modules [--config PATH] RUN_ID`.
|
||||
- Remove `inspect briefing` from parser support and help text in this stage.
|
||||
- Keep old data package generation in place only until Stage 8, but do not
|
||||
leave generation without a module snapshot.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Generated runs persist module snapshots before prompt package construction.
|
||||
- `inspect modules` returns the module snapshot.
|
||||
- `inspect briefing` is gone from help text and parser tests.
|
||||
- Metadata links the module snapshot path.
|
||||
- Existing report generation still succeeds with fake Scriptorium.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/state ./internal/cli ./internal/app
|
||||
go run ./cmd/weatherreporter --help
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 8: YAML Prompt Package Cutover
|
||||
|
||||
Goal: replace JSON prompt data packages with YAML named-stanza data packages.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/promptinput`;
|
||||
- `internal/state`;
|
||||
- `internal/adapters/scriptorium` tests;
|
||||
- `internal/app`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Set prompt package schema version to `weatherreporter.data_package.v2`.
|
||||
- Build prompt package content from module snapshots, report metadata, recent
|
||||
changes, and source warnings.
|
||||
- Save prompt packages as `.data_package.yaml`.
|
||||
- Continue passing Scriptorium input as `--input data_package=<path>`.
|
||||
- Update render/run tests to avoid assuming `.json` filenames.
|
||||
- Ensure YAML uses named stanzas under `briefing`.
|
||||
- Omit empty optional fields.
|
||||
- Keep module snapshot JSON as the comparison/inspection source.
|
||||
- Update metadata `DataPackagePath` to point to YAML.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Scriptorium render and run receive a YAML `data_package` path.
|
||||
- YAML output is deterministic enough for tests.
|
||||
- `inspect data-package` returns YAML content or a parsed representation
|
||||
chosen consistently in CLI tests.
|
||||
- No code assumes data package paths end in `.json`.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/promptinput ./internal/adapters/scriptorium ./internal/state ./internal/app ./internal/cli
|
||||
go test ./...
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 9: App Orchestration Cutover
|
||||
|
||||
Goal: make module execution the only generation path for all implemented
|
||||
reports.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/app`;
|
||||
- `internal/briefing`;
|
||||
- `internal/facts`;
|
||||
- app workflow tests.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- In `GenerateReport`, fetch Weather API data once, build `CollectedFacts`,
|
||||
build `DerivedFacts`, execute configured modules, save module snapshot, build
|
||||
YAML prompt package, then continue preflight/run/metadata/distributor flow.
|
||||
- Preserve ordering:
|
||||
1. resolve prior comparable metadata;
|
||||
2. fetch bundle;
|
||||
3. build facts;
|
||||
4. execute modules;
|
||||
5. save module snapshot;
|
||||
6. compute Recent Changes;
|
||||
7. save YAML data package;
|
||||
8. run render preflight;
|
||||
9. save metadata;
|
||||
10. run Scriptorium;
|
||||
11. copy optional output;
|
||||
12. save final metadata;
|
||||
13. notify distributor if enabled.
|
||||
- Do not use `--out` or `--out-dir` copies for distributor notification.
|
||||
- Do not invoke modules after Scriptorium failures.
|
||||
- Keep batch behavior unchanged: continue independent reports, return nonzero
|
||||
aggregate status if any report fails.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Daily, Tomorrow, 3-Day, Weekend, and Storm generation all use module
|
||||
snapshots and YAML data packages.
|
||||
- Existing public CLI syntax remains stable except `inspect modules` replacing
|
||||
`inspect briefing`.
|
||||
- Managed Markdown report paths and distributor upload source remain stable.
|
||||
- App tests assert generated module snapshots and YAML data packages.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/app ./internal/cli ./internal/state ./internal/briefing ./internal/promptinput
|
||||
go test ./...
|
||||
```
|
||||
|
||||
This stage may be large. Split by report family if needed: Daily/Tomorrow,
|
||||
Outlooks, then Storm.
|
||||
|
||||
## Stage 10: Recent Changes Migration
|
||||
|
||||
Goal: compare structured module snapshots instead of report-shaped briefing
|
||||
packages.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/changes`;
|
||||
- `internal/state`;
|
||||
- `internal/app`;
|
||||
- changes tests.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Define which module stanzas each comparison strategy consumes.
|
||||
- Daily comparison should use `derived_daily_summary`,
|
||||
`derived_daypart_summaries`, `alert_digest`, and `precip_timing` where
|
||||
present.
|
||||
- 3-Day and Weekend comparisons should use module snapshot outputs that replace
|
||||
current outlook day comparisons.
|
||||
- Storm comparison should remain explicit-window based and consume storm
|
||||
module outputs when implemented.
|
||||
- Do not compare rendered Markdown or rendered YAML text.
|
||||
- If a comparison-required module is missing, return an actionable error or an
|
||||
inspectable warning according to the report policy chosen in code. Prefer an
|
||||
error for required comparison modules and no-op only for optional comparison
|
||||
stanzas.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Prior snapshot lookup still uses report compatibility and valid-period rules.
|
||||
- Recent Changes output remains deterministic.
|
||||
- Tests cover unchanged forecasts, threshold-crossing changes, alert changes,
|
||||
precip timing changes, and missing comparison stanzas.
|
||||
- Old `briefing.Package` comparison code is removed.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/changes ./internal/state ./internal/app
|
||||
go test ./...
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt if module snapshots are
|
||||
already available.
|
||||
|
||||
## Stage 11: Remove Old Briefing Shapes
|
||||
|
||||
Goal: remove obsolete report-shaped briefing containers and stale JSON package
|
||||
assumptions.
|
||||
|
||||
Files/packages to change:
|
||||
|
||||
- `internal/briefing`;
|
||||
- `internal/promptinput`;
|
||||
- `internal/state`;
|
||||
- `internal/app`;
|
||||
- tests throughout `internal`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Remove old `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing container
|
||||
structs when no longer used.
|
||||
- Remove old `briefing.Package` if it no longer represents the module
|
||||
snapshot. If the package keeps a `Package` type, it must be module-oriented.
|
||||
- Remove tests that construct old report-shaped briefing fixtures.
|
||||
- Remove stale `.data_package.json` assumptions.
|
||||
- Remove dead helper functions that only supported old report-shaped output.
|
||||
- Keep generated report Markdown behavior stable.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'`
|
||||
has no production-code matches, except deliberate roadmap/history references
|
||||
where appropriate.
|
||||
- No old report-shaped content structs remain on the generation path.
|
||||
- All tests pass.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 12: Documentation And Example Alignment
|
||||
|
||||
Goal: align non-roadmap docs with implemented module behavior.
|
||||
|
||||
Files to inspect/update:
|
||||
|
||||
- `README.md`, only if the orientation or quickstart changed;
|
||||
- `docs/cli.md`;
|
||||
- `docs/config.md`;
|
||||
- `docs/operations.md`;
|
||||
- `docs/troubleshooting.md`;
|
||||
- `docs/internal/app-orchestration.md`;
|
||||
- `docs/internal/briefing.md` or replacement module internals doc;
|
||||
- `docs/internal/changes.md`;
|
||||
- `docs/internal/forecast-derivation.md`;
|
||||
- `docs/internal/prompt-input.md`;
|
||||
- `docs/internal/state.md`;
|
||||
- `docs/internal/weather-data.md`;
|
||||
- `docs/integrations/scriptorium.md`;
|
||||
- `examples/config.yml`.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Document only implemented behavior outside `docs/roadmap/`.
|
||||
- Add or update an internal module contract document if module behavior is now
|
||||
implemented.
|
||||
- Document `inspect modules` and remove `inspect briefing`.
|
||||
- Document YAML data packages and JSON module snapshots.
|
||||
- Document report module overrides and typed options only if implemented.
|
||||
- Keep QPF as future-only unless upstream support was added.
|
||||
- Keep Scriptorium contract focused on `--input data_package=<path>` and the
|
||||
actual file format now passed.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Non-roadmap docs no longer describe old report-shaped briefing packages.
|
||||
- Config examples load.
|
||||
- CLI examples match `weatherreporter --help`.
|
||||
- Docs clearly distinguish module snapshots from prompt data packages.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
rg -n "inspect briefing|data_package\\.json|report-shaped|vars-file|promptvars" README.md docs examples internal -g '!docs/roadmap/**'
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 13: Final Validation
|
||||
|
||||
Goal: run full validation and catch stale assumptions after the cutover.
|
||||
|
||||
Required commands:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Required grep checks:
|
||||
|
||||
```bash
|
||||
rg -n "inspect briefing|data_package\\.json|briefing\\.Package|Daily struct|ThreeDay struct|Weekend struct|Storm struct" internal docs examples -g '!docs/roadmap/**'
|
||||
rg -n "measurable_qpf_total_in|max_hourly_qpf_in" internal docs examples -g '!docs/roadmap/**'
|
||||
```
|
||||
|
||||
Expected grep results:
|
||||
|
||||
- no production-code references to `inspect briefing`;
|
||||
- no production-code assumption that prompt packages are JSON;
|
||||
- no production-code dependence on old report-shaped briefing containers;
|
||||
- QPF references appear only as future-target docs or omitted-field tests until
|
||||
upstream QPF exists.
|
||||
|
||||
Manual review:
|
||||
|
||||
- Generate command output still writes managed Markdown reports.
|
||||
- Batch behavior still continues independent reports and returns nonzero on
|
||||
aggregate failure.
|
||||
- Distributor notification still uploads the managed Markdown report, not
|
||||
module snapshots or YAML prompt packages.
|
||||
- Secrets are not printed or persisted.
|
||||
- YAML prompt package is readable and contains named stanzas.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
Do not include these in the initial module cutover:
|
||||
|
||||
- plugin architecture;
|
||||
- dynamic module loading;
|
||||
- YAML-defined module schemas;
|
||||
- user-authored module code;
|
||||
- module-owned Weather API fetching;
|
||||
- QPF fields before upstream QPF exists;
|
||||
- SPC modules before upstream SPC data exists;
|
||||
- radar modules before upstream radar data exists;
|
||||
- event/storm-review reports unless a separate roadmap implements them.
|
||||
|
||||
## Open Questions
|
||||
|
||||
No blocking open questions remain for this implementation plan. The previously
|
||||
identified choices are locked above:
|
||||
|
||||
- persist JSON module snapshots and YAML prompt data packages;
|
||||
- split broad normalized source types into `internal/weatherdata`;
|
||||
- replace `inspect briefing` with `inspect modules`;
|
||||
- support typed module options from the first config implementation.
|
||||
677
docs/roadmap/modules.md
Normal file
677
docs/roadmap/modules.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# Modular Data Package Roadmap
|
||||
|
||||
This roadmap describes planned refactoring work that is not implemented.
|
||||
Current behavior is documented outside `docs/roadmap/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
Move weatherreporter toward deterministic, reusable briefing modules that can
|
||||
be composed per report type. The goal is to make prompt input easier for the
|
||||
LLM to understand, easier for operators to inspect, and easier for developers
|
||||
to change without touching a cross-cutting set of report-builder files.
|
||||
|
||||
The target outcome is a prompt-facing YAML data package with named stanzas. Each
|
||||
stanza should be built by a self-contained module that derives clear,
|
||||
deterministic facts from normalized forecast inputs. Reports should choose
|
||||
modules by ordered module IDs, so experimenting with a report can be as small
|
||||
as changing one configuration line, plus any matching prompt change outside
|
||||
weatherreporter.
|
||||
|
||||
The internal target shape is:
|
||||
|
||||
```text
|
||||
CollectedFacts -> DerivedFacts -> ModuleOutput
|
||||
```
|
||||
|
||||
Each arrow should be a stable internal contract. `DerivedFacts` should not need
|
||||
to know how `CollectedFacts` were collected. Modules should not need to know
|
||||
the provenance of any collected or derived fact they consume. Report building
|
||||
should not need to know how a module sourced its underlying facts or calculated
|
||||
its output.
|
||||
|
||||
## Intent And Context
|
||||
|
||||
The current application already curates source data before passing it to the
|
||||
LLM. This refactor should strengthen that design. Modules should not expose raw
|
||||
source complexity merely because it is available. They should compute and
|
||||
package the facts the LLM should not have to infer from raw hourly periods,
|
||||
alerts, narrative periods, forecast discussions, or weather stories.
|
||||
|
||||
The desired module behavior is deterministic. A module should answer a narrow
|
||||
question such as:
|
||||
|
||||
- what are the current conditions;
|
||||
- what are the key daily forecast facts;
|
||||
- what are the daypart summaries;
|
||||
- when is precipitation most likely;
|
||||
- which alerts overlap the report period;
|
||||
- what short-term AFD text is relevant;
|
||||
- what weather story text is relevant.
|
||||
|
||||
The primary maintainability goal is local reasoning. For example, updating the
|
||||
derived daily summary should mostly involve one module implementation and its
|
||||
tests. Adding AFD short-term text to a future `next_six_hours` report should be
|
||||
a report composition change, not a copy/paste change across multiple builders.
|
||||
|
||||
## Target Prompt Shape
|
||||
|
||||
The target prompt-facing data package should be YAML with named stanzas under
|
||||
`briefing`. Named stanzas are preferred over an array of generic module objects
|
||||
because they are easier to read, inspect, and reference in prompts.
|
||||
|
||||
Example target shape:
|
||||
|
||||
```yaml
|
||||
report:
|
||||
id: daily_today
|
||||
prompt_id: weather.daily_report
|
||||
generated_at: 2026-06-09T07:15:00-05:00
|
||||
timezone: America/Chicago
|
||||
current_local_date: 2026-06-09
|
||||
valid_period:
|
||||
start: 2026-06-09T00:00:00-05:00
|
||||
end: 2026-06-10T00:00:00-05:00
|
||||
|
||||
briefing:
|
||||
metadata:
|
||||
location:
|
||||
id: home
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
timezone: America/Chicago
|
||||
|
||||
current_conditions:
|
||||
condition_text: Partly cloudy
|
||||
temperature_f: 74
|
||||
apparent_temperature_f: 76
|
||||
dewpoint_f: 66
|
||||
relative_humidity_percent: 71
|
||||
wind_speed_mph: 8
|
||||
wind_direction_degrees: 190
|
||||
|
||||
derived_daily_summary:
|
||||
high_temp_f: 86
|
||||
low_temp_f: 68
|
||||
max_pop_percent: 70
|
||||
max_pop_window: "2 PM-6 PM"
|
||||
measurable_qpf_total_in: 0.35
|
||||
max_hourly_qpf_in: 0.12
|
||||
first_precip_hour: "1 PM"
|
||||
last_precip_hour: "8 PM"
|
||||
thunder_mentioned: true
|
||||
max_wind_gust_mph: 28
|
||||
heat_index_max_f: 91
|
||||
|
||||
derived_daypart_summaries:
|
||||
morning:
|
||||
temp_range_f: "70-78"
|
||||
max_pop_percent: 20
|
||||
dominant_condition: Partly sunny
|
||||
afternoon:
|
||||
temp_range_f: "82-86"
|
||||
max_pop_percent: 70
|
||||
dominant_condition: Showers and thunderstorms likely
|
||||
|
||||
alert_digest:
|
||||
checked: true
|
||||
active_count: 0
|
||||
relevant_count: 0
|
||||
|
||||
area_forecast_discussion:
|
||||
key_messages:
|
||||
- Scattered storms are possible this afternoon.
|
||||
short_term: Showers and storms increase during the afternoon.
|
||||
long_term: Periodic rain chances continue into the weekend.
|
||||
|
||||
weather_story:
|
||||
available: true
|
||||
title: Several Chances for Rain Through Monday
|
||||
description: Scattered showers and thunderstorms remain possible.
|
||||
|
||||
recent_changes:
|
||||
items: []
|
||||
```
|
||||
|
||||
Field names should include units where the unit is not obvious:
|
||||
`high_temp_f`, `max_pop_percent`, `measurable_qpf_total_in`,
|
||||
`max_wind_gust_mph`, and similar names are preferred over ambiguous generic
|
||||
names. Time and range strings should be formatted for prompt readability, while
|
||||
machine-oriented timestamps should remain available in report metadata.
|
||||
|
||||
The QPF fields in the example are target output fields for a future upstream
|
||||
source. They should not be treated as immediately implementable from the
|
||||
current weatherfeeder-backed `CollectedFacts` sources. Until an upstream QPF
|
||||
source exists, QPF fields should be omitted rather than fabricated from
|
||||
precipitation probability or narrative text.
|
||||
|
||||
## Architecture Target
|
||||
|
||||
Keep the current package boundaries:
|
||||
|
||||
- `internal/forecast` owns normalized source data, deterministic forecast
|
||||
derivation, period slicing, daypart grouping, and weather-signal calculations.
|
||||
- `internal/briefing` owns prompt-facing module builders and module output
|
||||
schemas.
|
||||
- `internal/report` owns report identity, prompt ID, valid-period resolution,
|
||||
output naming, comparison strategy, and default module composition.
|
||||
- `internal/config` owns optional report module composition overrides.
|
||||
- `internal/promptinput` owns final data-package assembly, validation, and
|
||||
prompt-facing serialization.
|
||||
- `internal/app` remains orchestration: resolve report, fetch sources, build
|
||||
module context, execute configured modules, persist artifacts, run
|
||||
Scriptorium, and notify distributor.
|
||||
|
||||
Do not move source fetching, subprocess execution, distributor upload behavior,
|
||||
or raw external dependency types into module code.
|
||||
|
||||
## Layered Fact Contracts
|
||||
|
||||
Introduce explicit internal contracts for three layers:
|
||||
|
||||
1. `CollectedFacts`
|
||||
2. `DerivedFacts`
|
||||
3. `ModuleOutput`
|
||||
|
||||
`CollectedFacts` are normalized upstream inputs collected once per report run.
|
||||
They should be broad and source-oriented, but not tied to Weather API transport
|
||||
details. Examples include current conditions, observations, hourly forecast
|
||||
runs, narrative forecast runs, active alerts, AFD discussion, weather story,
|
||||
future radar inputs, and future historical observation totals.
|
||||
|
||||
`DerivedFacts` are reusable, report-scoped deterministic products calculated
|
||||
from `CollectedFacts`. They may slice, combine, group, or summarize collected
|
||||
facts when the result is broadly useful to more than one module or needed for
|
||||
consistent behavior across modules. Examples include valid-period hourly
|
||||
periods, valid-period narrative periods, alert overlaps, daily summaries,
|
||||
configured daypart summaries, and reusable precipitation timing windows.
|
||||
|
||||
`ModuleOutput` is the prompt-facing output contract produced by one module. A
|
||||
module may pass through raw-ish facts, such as AFD text, or expose derived
|
||||
facts, such as daily summary fields. In both cases, the module owns the named
|
||||
stanza shape and should produce stable, readable, unit-explicit prompt fields.
|
||||
|
||||
Unless implementation discovers a strong reason otherwise, the internal
|
||||
contract for accessing `CollectedFacts` and `DerivedFacts` should have the same
|
||||
shape:
|
||||
|
||||
- typed Go structs with named fields;
|
||||
- nil pointers, empty slices, or zero values to represent absent facts;
|
||||
- no `map[string]any` or string-keyed fact lookup as the primary API;
|
||||
- immutable-by-convention values once passed to modules;
|
||||
- helper methods only for repeated access patterns that would otherwise be
|
||||
error-prone;
|
||||
- source provenance and warnings stored separately from the primary fact
|
||||
values, available to metadata/source-warning modules but not required by
|
||||
ordinary modules.
|
||||
|
||||
Illustrative shape:
|
||||
|
||||
```go
|
||||
type CollectedFacts struct {
|
||||
Current *weatherdata.CurrentConditions
|
||||
Observations *weatherdata.ObservationRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Discussion *weatherdata.Discussion
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
// Future: radar, historical observations, snow/rain totals, etc.
|
||||
}
|
||||
|
||||
type DerivedFacts struct {
|
||||
HourlyPeriods []weatherdata.ForecastPeriod
|
||||
NarrativePeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []weatherdata.AlertOverlap
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
PrecipTiming *forecast.PrecipTiming
|
||||
}
|
||||
|
||||
type ModuleContext struct {
|
||||
Report report.Resolved
|
||||
Collected CollectedFacts
|
||||
Derived DerivedFacts
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
```
|
||||
|
||||
The exact package names may differ during implementation. The important
|
||||
boundary is semantic: `CollectedFacts` represent upstream facts after
|
||||
normalization; `DerivedFacts` represent reusable report-scoped transformations;
|
||||
modules represent prompt-facing stanza construction.
|
||||
|
||||
### DerivedFacts Boundary
|
||||
|
||||
Be conservative about what belongs in `DerivedFacts`. Add a value to this layer
|
||||
only when it is:
|
||||
|
||||
- deterministic;
|
||||
- report-scoped;
|
||||
- reusable by multiple modules or needed to keep modules consistent;
|
||||
- independent of prompt wording and presentation decisions.
|
||||
|
||||
`DerivedFacts` may:
|
||||
|
||||
- slice source periods to the report valid period;
|
||||
- group hourly data into configured dayparts;
|
||||
- compute reusable summaries;
|
||||
- compute alert overlaps;
|
||||
- normalize repeated time-window selections.
|
||||
|
||||
`DerivedFacts` should not:
|
||||
|
||||
- decide prompt-facing wording;
|
||||
- decide which facts are important for one module only;
|
||||
- format prose-like strings for the LLM;
|
||||
- fetch upstream data;
|
||||
- write artifacts;
|
||||
- depend on Scriptorium or distributor.
|
||||
|
||||
Module-specific calculations should remain inside the module when they are
|
||||
presentation-specific, used by only one module, or likely to change while
|
||||
tuning prompt behavior.
|
||||
|
||||
## Module Model
|
||||
|
||||
Introduce a typed module model rather than generic maps. A module should have:
|
||||
|
||||
- stable module ID;
|
||||
- self-contained output struct;
|
||||
- one focused builder function;
|
||||
- fixture or unit tests near the module;
|
||||
- declared input requirements, such as hourly forecast, alerts, discussion, or
|
||||
weather story;
|
||||
- deterministic handling for missing optional source data;
|
||||
- prompt-facing field names that are stable and unit-explicit.
|
||||
|
||||
The implementation may use a simple function registry rather than a broad
|
||||
interface if that is enough:
|
||||
|
||||
```go
|
||||
type ModuleID string
|
||||
|
||||
type ModuleBuilder func(ModuleContext) (ModuleOutput, error)
|
||||
```
|
||||
|
||||
`ModuleOutput` should include the stable module ID, the YAML stanza name, and a
|
||||
typed value owned by the module:
|
||||
|
||||
```go
|
||||
type ModuleOutput struct {
|
||||
ID ModuleID
|
||||
StanzaName string
|
||||
Value any
|
||||
}
|
||||
```
|
||||
|
||||
The module registry should preserve output order from report composition, but
|
||||
the serialized YAML should use named stanzas for clarity.
|
||||
|
||||
Each module should be able to produce exactly one named stanza. If one source
|
||||
can usefully feed multiple stanzas, split that into multiple modules rather than
|
||||
making one module produce unrelated output.
|
||||
|
||||
## Report Composition Target
|
||||
|
||||
Report definitions should declare default ordered module IDs. Configuration may
|
||||
override the ordered module list for implemented reports.
|
||||
|
||||
Illustrative future config shape:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
next_6_hours:
|
||||
deterministic_modules:
|
||||
- hourly_table
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- afd_short_term_text
|
||||
- weather_story_text
|
||||
- spc_products
|
||||
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- current_conditions
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- forecast_delta
|
||||
- afd_short_term_text
|
||||
- weather_story_text
|
||||
- spc_products
|
||||
```
|
||||
|
||||
Configuration should validate unknown module IDs, duplicate module IDs when
|
||||
duplicates are not meaningful, and modules that are incompatible with the
|
||||
selected report period. Defaults should remain in Go so the application works
|
||||
without report composition config.
|
||||
|
||||
## Clean-Break Cutover Policy
|
||||
|
||||
This project is still pre-release. Prefer a direct cutover to the new internal
|
||||
shape instead of preserving transitional report-shaped briefing structures.
|
||||
|
||||
Implementation should:
|
||||
|
||||
- replace report-shaped briefing containers with module-oriented snapshots;
|
||||
- replace JSON prompt package output with YAML prompt package output;
|
||||
- update inspect commands, Recent Changes, tests, and docs in the same cutover;
|
||||
- remove obsolete `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing
|
||||
container shapes when no longer needed;
|
||||
- avoid compatibility aliases unless they materially reduce implementation
|
||||
risk inside one stage.
|
||||
|
||||
The public CLI command names, report IDs, prompt IDs, RunID format, managed
|
||||
Markdown report paths, and distributor upload source should remain stable unless
|
||||
a separate roadmap explicitly changes them.
|
||||
|
||||
## Artifact And State Target
|
||||
|
||||
The durable artifacts should reflect the new module-oriented model.
|
||||
|
||||
Recommended target:
|
||||
|
||||
- module snapshot artifact: structured JSON for stable inspection, state
|
||||
lookup, and Recent Changes comparisons;
|
||||
- prompt data package artifact: YAML with named stanzas, passed to Scriptorium
|
||||
as `data_package`;
|
||||
- metadata artifact: JSON linking the module snapshot, YAML data package,
|
||||
preflight output, rendered report, source warnings, source hashes, and
|
||||
distributor notification artifact when present.
|
||||
|
||||
The workspace path names should make the artifact type clear. A future
|
||||
implementation may keep the existing `data-packages/` directory, but file
|
||||
extensions and metadata fields should reflect the real format, such as:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
|
||||
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
|
||||
```
|
||||
|
||||
Replace `inspect briefing` with `inspect modules` during the cutover.
|
||||
`inspect modules` should return the module snapshot. `inspect data-package`
|
||||
should return the YAML artifact or a parsed representation of the YAML artifact.
|
||||
Do not leave inspect commands pointed at obsolete report-shaped data.
|
||||
|
||||
## Module Options And Compatibility
|
||||
|
||||
Each module should have a typed options struct, even when initially empty.
|
||||
Configuration may decode module options from YAML, but internal module builders
|
||||
should receive typed options rather than `map[string]any`.
|
||||
|
||||
Illustrative config shape:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
next_6_hours:
|
||||
deterministic_modules:
|
||||
- id: hourly_table
|
||||
options:
|
||||
range: valid_period
|
||||
fields:
|
||||
- time
|
||||
- temperature_f
|
||||
- pop_percent
|
||||
- wind_gust_mph
|
||||
- id: afd_short_term_text
|
||||
```
|
||||
|
||||
Module definitions should declare:
|
||||
|
||||
- module ID;
|
||||
- stanza name;
|
||||
- typed options schema;
|
||||
- supported report IDs or report categories;
|
||||
- required collected facts;
|
||||
- required derived facts;
|
||||
- whether missing optional facts omit the stanza, emit an empty stanza, or
|
||||
produce a warning;
|
||||
- whether duplicate use of the module is allowed.
|
||||
|
||||
Configuration validation should reject:
|
||||
|
||||
- unknown report IDs;
|
||||
- unknown module IDs;
|
||||
- duplicate module IDs unless explicitly allowed;
|
||||
- two modules that render the same stanza name;
|
||||
- module options that do not match the module's typed option schema;
|
||||
- modules that are incompatible with the report's valid-period strategy or
|
||||
available facts.
|
||||
|
||||
## Recent Changes Target
|
||||
|
||||
Recent Changes must remain structured and deterministic. During the clean-break
|
||||
cutover, move comparison inputs away from report-shaped `briefing.Package`
|
||||
values and toward module-oriented snapshots.
|
||||
|
||||
Recommended target:
|
||||
|
||||
- compare `ModuleOutput` values or typed module snapshot stanzas, not rendered
|
||||
YAML and not rendered Markdown;
|
||||
- keep report-compatible matching policy in `internal/report`;
|
||||
- keep threshold configuration in `internal/config`;
|
||||
- keep comparison algorithms in `internal/changes`;
|
||||
- make each comparison explicit about which module stanzas it needs.
|
||||
|
||||
For example, Daily comparison should primarily consume
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `alert_digest`, and
|
||||
`precip_timing` if present. If a required stanza is missing, the comparison
|
||||
should return no change with an inspectable warning or an actionable error,
|
||||
depending on the report's configured missing-data policy.
|
||||
|
||||
## Package Naming Target
|
||||
|
||||
The current `internal/forecast` package owns both forecast-specific derivation
|
||||
and broader normalized weather data. Because planned sources include current
|
||||
observations, radar, and historical review inputs, implementation should
|
||||
consider splitting names during the clean-break refactor:
|
||||
|
||||
- `internal/weatherdata`: normalized collected source facts, source metadata,
|
||||
warnings, and broad weather data types;
|
||||
- `internal/forecast`: forecast-specific algorithms such as period slicing,
|
||||
daily summaries, daypart grouping, and precipitation timing.
|
||||
|
||||
If this split is too large for the first cutover, introduce `CollectedFacts` in
|
||||
the package that minimizes churn, but avoid expanding the meaning of
|
||||
`internal/forecast` further in new module contracts.
|
||||
|
||||
## Initial Module Candidates
|
||||
|
||||
The first module catalog should start with the modules needed to replace current
|
||||
report-shaped briefing output and should clearly distinguish implemented
|
||||
modules from future-only candidates.
|
||||
|
||||
Initial candidates:
|
||||
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `hourly_table`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `area_forecast_discussion`
|
||||
- `afd_key_messages`
|
||||
- `afd_short_term_text`
|
||||
- `afd_long_term_text`
|
||||
- `weather_story`
|
||||
- `forecast_delta`
|
||||
- `outdoor_windows`
|
||||
- `weekend_planning`
|
||||
- `storm_window_summary`
|
||||
|
||||
Each module should declare inputs, outputs, report applicability,
|
||||
missing-data behavior, compatibility behavior, and options.
|
||||
|
||||
## Target Derived Daily Summary
|
||||
|
||||
The intended `derived_daily_summary` shape is:
|
||||
|
||||
```yaml
|
||||
derived_daily_summary:
|
||||
high_temp_f: 86
|
||||
low_temp_f: 68
|
||||
max_pop_percent: 70
|
||||
max_pop_window: "2 PM-6 PM"
|
||||
measurable_qpf_total_in: 0.35
|
||||
max_hourly_qpf_in: 0.12
|
||||
first_precip_hour: "1 PM"
|
||||
last_precip_hour: "8 PM"
|
||||
thunder_mentioned: true
|
||||
max_wind_gust_mph: 28
|
||||
heat_index_max_f: 91
|
||||
```
|
||||
|
||||
`measurable_qpf_total_in` and `max_hourly_qpf_in` are future target fields.
|
||||
They require a real upstream quantitative precipitation source and should be
|
||||
omitted until such a source is represented in `CollectedFacts`.
|
||||
|
||||
## Target Derived Daypart Summaries
|
||||
|
||||
The intended `derived_daypart_summaries` shape is:
|
||||
|
||||
```yaml
|
||||
derived_daypart_summaries:
|
||||
morning:
|
||||
temp_range_f: "70-78"
|
||||
max_pop_percent: 20
|
||||
dominant_condition: Partly sunny
|
||||
afternoon:
|
||||
temp_range_f: "82-86"
|
||||
max_pop_percent: 70
|
||||
dominant_condition: Showers and thunderstorms likely
|
||||
```
|
||||
|
||||
## Configurable Composition Target
|
||||
|
||||
The target configuration model should allow report module composition to be
|
||||
changed without editing cross-cutting report-builder code. Built-in defaults
|
||||
should remain in Go so the application works with no module override config.
|
||||
|
||||
Illustrative config:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- current_conditions
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- afd_short_term_text
|
||||
- weather_story_text
|
||||
```
|
||||
|
||||
Adding or removing an implemented module from an implemented report should be a
|
||||
single config edit. Unknown modules, invalid options, duplicate stanzas, and
|
||||
incompatible report/module combinations should fail with actionable errors.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The refactor is complete when:
|
||||
|
||||
- current implemented reports generate successfully from named-stanza YAML
|
||||
prompt packages;
|
||||
- module snapshots are persisted as structured JSON and linked from metadata;
|
||||
- `inspect modules` returns module snapshots;
|
||||
- `inspect briefing` is removed from CLI help, parser support, and
|
||||
non-roadmap docs;
|
||||
- Recent Changes compares structured module snapshots, not rendered Markdown or
|
||||
YAML text;
|
||||
- report definitions declare default module order in one place;
|
||||
- implemented report module composition can be overridden by config;
|
||||
- implemented modules have typed options and compatibility contracts;
|
||||
- `CollectedFacts` are built once per report run and reused by all modules;
|
||||
- `DerivedFacts` are built from `CollectedFacts` and do not depend on adapter
|
||||
transport details;
|
||||
- module builders do not call Weather API, Scriptorium, distributor, or
|
||||
filesystem state directly;
|
||||
- stale report-shaped briefing containers are removed from the generation path;
|
||||
- QPF output fields remain omitted until upstream QPF exists.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Keep modules deterministic.
|
||||
- Keep modules self-contained where practical.
|
||||
- Preserve the `CollectedFacts -> DerivedFacts -> ModuleOutput` boundary.
|
||||
- Build `CollectedFacts` once per report run.
|
||||
- Build `DerivedFacts` from `CollectedFacts`, not from adapter-specific
|
||||
transport details.
|
||||
- Keep `DerivedFacts` conservative and reusable.
|
||||
- Keep raw external source details behind adapters and forecast normalization.
|
||||
- Keep report composition centralized and ordered.
|
||||
- Prefer typed outputs over generic maps.
|
||||
- Prefer typed fact contracts over string-keyed fact registries.
|
||||
- Prefer named YAML stanzas over generic module arrays.
|
||||
- Use unit-explicit field names.
|
||||
- Do not require the LLM to calculate obvious derived facts.
|
||||
- Do not let module builders call external services or write durable state.
|
||||
- Do not introduce plugins, dynamic loading, or a generic workflow engine.
|
||||
|
||||
## Risks And Mitigations
|
||||
|
||||
- Prompt contract churn: stage YAML introduction after module outputs are
|
||||
tested and inspectable.
|
||||
- Recent Changes drift: compare stable module outputs and keep snapshot tests.
|
||||
- Over-abstraction: start with simple builders and a registry, not a framework.
|
||||
- Config complexity: expose ordered module selection and typed options only for
|
||||
implemented modules; defer broad parameterization.
|
||||
- Loss of useful context: preserve focused source excerpts and source warnings,
|
||||
but avoid reintroducing raw unbounded payloads.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
These are out of scope for the initial module refactor:
|
||||
|
||||
- dynamic plugin loading;
|
||||
- user-defined module code;
|
||||
- YAML-defined module schemas;
|
||||
- module parameterization beyond implemented typed options;
|
||||
- replacing Weather API source fetching with module-owned fetches;
|
||||
- moving prompt authoring or Scriptorium prompt changes into weatherreporter;
|
||||
- adding future source modules, such as SPC products, before upstream data and
|
||||
report requirements exist.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Should module snapshots and prompt packages both be persisted?
|
||||
|
||||
Recommended approach: persist module snapshots as JSON and prompt packages as
|
||||
YAML. JSON module snapshots are better for structured Recent Changes, state
|
||||
lookup, and tests. YAML prompt packages are better for prompt readability and
|
||||
LLM consumption. Keeping both artifacts gives each use case the right format
|
||||
without asking comparison code to parse prompt-oriented YAML.
|
||||
|
||||
Viable alternative: persist only the YAML prompt package and parse it for
|
||||
inspection and Recent Changes. This reduces artifact count, but it couples
|
||||
machine comparison to prompt formatting and makes future prompt-oriented
|
||||
formatting changes riskier.
|
||||
|
||||
### Should `internal/forecast` be split during the first cutover?
|
||||
|
||||
Recommended approach: split broad normalized source types into
|
||||
`internal/weatherdata` during the clean-break cutover if the implementation
|
||||
scope remains manageable. This name fits current conditions, alerts,
|
||||
discussion, weather story, future radar, and future historical data better than
|
||||
`forecast`.
|
||||
|
||||
Viable alternative: keep existing `internal/forecast` types for the first
|
||||
module implementation and introduce `CollectedFacts` as a wrapper. This reduces
|
||||
short-term churn, but it leaves a package name that will become increasingly
|
||||
misleading as non-forecast sources grow.
|
||||
|
||||
### Should module config support typed options immediately?
|
||||
|
||||
Recommended approach: support typed options for implemented modules from the
|
||||
start, even if most modules use empty options. This establishes the extension
|
||||
point needed for hourly ranges, field selection, and AFD section choices
|
||||
without adding dynamic maps to module builders.
|
||||
|
||||
Viable alternative: initially support only ordered module IDs and add options
|
||||
later. This is simpler, but it may force another config shape change as soon as
|
||||
hourly range or section-selection experiments begin.
|
||||
@@ -195,10 +195,115 @@ Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
||||
lines.
|
||||
|
||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||
metadata, sources, briefing, and data package for that RunID.
|
||||
metadata, sources, module snapshot, and data package for that RunID.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||
|
||||
## Invalid Secrets Directory
|
||||
|
||||
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
||||
environment variable name context.
|
||||
|
||||
Likely cause: `secrets.directory` points to a missing directory or contains an
|
||||
invalid entry. Secret entries must be regular files directly under the
|
||||
configured directory, and file basenames must match
|
||||
`[A-Za-z_][A-Za-z0-9_]*`.
|
||||
|
||||
Diagnostic: list the configured directory and inspect entry names and file
|
||||
types. Do not print secret file contents.
|
||||
|
||||
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
|
||||
filenames, and ensure the weatherreporter process can read each secret file.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## Distributor Token Is Missing
|
||||
|
||||
Symptom: notification fails with a message that the distributor token
|
||||
environment variable is not set.
|
||||
|
||||
Likely cause: `notify.distributor.enabled` is true, but the environment
|
||||
variable named by `notify.distributor.token_env` was not populated directly or
|
||||
through `secrets.directory`.
|
||||
|
||||
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
|
||||
file exists under `secrets.directory` or that the process environment includes
|
||||
the variable. Do not print the token value.
|
||||
|
||||
Safe fix: create a readable secret file whose basename matches `token_env`, or
|
||||
set the environment variable through the service manager.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Operations guide](operations.md).
|
||||
|
||||
## Distributor Upload Conflict
|
||||
|
||||
Symptom: notification fails with idempotency conflict context.
|
||||
|
||||
Likely cause: the same idempotency key was reused for different bundle content
|
||||
within the same distributor token and pipeline. By default the bundle ID is a
|
||||
stable report-stream identity and the idempotency key appends RunID.
|
||||
|
||||
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
||||
and idempotency context. Compare the configured templates with the report RunID
|
||||
and report path.
|
||||
|
||||
Also inspect the notification artifact linked from metadata. It records the
|
||||
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run
|
||||
status, status error, and raw run report JSON when available.
|
||||
|
||||
Safe fix: keep idempotency templates stable for retries of the same generated
|
||||
report, but do not reuse the same rendered key for different generated report
|
||||
content.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||
|
||||
## Distributor Upload Rejected
|
||||
|
||||
Symptom: notification fails with distributor upload rejection, HTTP status, or
|
||||
bundle validation context.
|
||||
|
||||
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
|
||||
ID, idempotency key, source file, or one of the rendered bundle paths.
|
||||
|
||||
Diagnostic: inspect stdout JSON or stderr status lines for
|
||||
`notificationError`. Confirm `notify.distributor.endpoint`,
|
||||
`notify.distributor.pipeline_id_template`,
|
||||
`notify.distributor.report_path_templates`, and token configuration. Token
|
||||
values are redacted from weatherreporter errors.
|
||||
|
||||
If the upload was accepted but destination output did not change, inspect the
|
||||
notification artifact's `runStatus.report`. Distributor actions such as
|
||||
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
|
||||
the destination handled the uploaded bundle.
|
||||
|
||||
Safe fix: fix the endpoint, token, templates, or distributor-side upload
|
||||
configuration. The weatherreporter upload source is the managed Markdown report,
|
||||
not `--out` or `--out-dir` copies.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Operations guide](operations.md),
|
||||
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||
|
||||
## Distributor Unavailable
|
||||
|
||||
Symptom: notification fails with network, timeout, or service unavailable
|
||||
context.
|
||||
|
||||
Likely cause: the configured distributor endpoint is unreachable, slow, or
|
||||
temporarily unavailable.
|
||||
|
||||
Diagnostic: check network access from the weatherreporter host to
|
||||
`notify.distributor.endpoint`. For batch runs, inspect which reports have
|
||||
`notificationStatus: "failed"`.
|
||||
|
||||
Safe fix: restore distributor service availability and rerun the affected
|
||||
report or batch. Stable idempotency keys make retrying the same generated report
|
||||
safe unless the distributor reports a conflict.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md).
|
||||
|
||||
## Unknown RunID
|
||||
|
||||
Symptom: an inspect command fails with `metadata for run id ... was not found`.
|
||||
|
||||
@@ -11,6 +11,22 @@ location:
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
secrets:
|
||||
directory: ""
|
||||
|
||||
notify:
|
||||
distributor:
|
||||
enabled: false
|
||||
endpoint: https://distributor.example.com
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
report_path_templates:
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
@@ -26,6 +42,7 @@ workspace:
|
||||
reports_dir: reports
|
||||
data_packages_dir: data-packages
|
||||
preflight_dir: preflight
|
||||
notifications_dir: notifications
|
||||
|
||||
dayparts:
|
||||
- name: overnight
|
||||
@@ -49,3 +66,23 @@ recent_change:
|
||||
precip_probability_points: 20
|
||||
wind_gust_miles_per_hour: 10
|
||||
precip_timing_shift_minutes: 120
|
||||
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- forecast_delta
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,3 +3,5 @@ module gitea.maximumdirect.net/eric/weatherreporter
|
||||
go 1.26
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
|
||||
48
go.sum
48
go.sum
@@ -1,3 +1,51 @@
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
371
internal/adapters/distributor/client.go
Normal file
371
internal/adapters/distributor/client.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Package distributor adapts weatherreporter report artifacts to distributor uploads.
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
distributorbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Endpoint string
|
||||
TokenEnv string
|
||||
Timeout time.Duration
|
||||
newUploadClient uploadClientFactory
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UploadFile struct {
|
||||
SourcePath string
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
UploadStatus string
|
||||
StatusError string
|
||||
RunStatus *RunStatus
|
||||
}
|
||||
|
||||
type RunStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
type IdempotencyConflictError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "distributor idempotency conflict"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type uploadClientFactory func(endpoint, token string, timeout time.Duration) (uploadClient, error)
|
||||
|
||||
type uploadClient interface {
|
||||
UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error)
|
||||
Status(ctx context.Context, runID string) (runStatus, error)
|
||||
}
|
||||
|
||||
type uploadFilesOptions struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type uploadFilesResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type runStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
const statusPollInterval = 250 * time.Millisecond
|
||||
|
||||
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||
return newClient(cfg, newDistributorUploadClient)
|
||||
}
|
||||
|
||||
func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory) *Client {
|
||||
if factory == nil {
|
||||
factory = newDistributorUploadClient
|
||||
}
|
||||
return &Client{
|
||||
Endpoint: cfg.Endpoint,
|
||||
TokenEnv: cfg.TokenEnv,
|
||||
Timeout: cfg.Timeout,
|
||||
newUploadClient: factory,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, error) {
|
||||
if c == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor client is nil")
|
||||
}
|
||||
if c.Endpoint == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor endpoint is required")
|
||||
}
|
||||
if c.TokenEnv == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable is required")
|
||||
}
|
||||
if req.PipelineID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor pipeline id is required")
|
||||
}
|
||||
if req.BundleID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle id is required")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor idempotency key is required for bundle %q", req.BundleID)
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload files are required for bundle %q", req.BundleID)
|
||||
}
|
||||
for i, file := range req.Files {
|
||||
if file.SourcePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor source path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
if file.BundlePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
}
|
||||
if c.newUploadClient == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload client factory is required for endpoint %q", c.Endpoint)
|
||||
}
|
||||
|
||||
token := os.Getenv(c.TokenEnv)
|
||||
if token == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable %q is not set", c.TokenEnv)
|
||||
}
|
||||
|
||||
uploadClient, err := c.newUploadClient(c.Endpoint, token, c.Timeout)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("create distributor upload client for endpoint %q: %w", c.Endpoint, redactToken(err, token))
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
cancel := func() {}
|
||||
if c.Timeout > 0 {
|
||||
runCtx, cancel = context.WithTimeout(runCtx, c.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
result, err := uploadClient.UploadFiles(runCtx, uploadFilesOptions{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Files: append([]UploadFile(nil), req.Files...),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{}, wrapUploadError(err, uploadErrorContext{
|
||||
Endpoint: c.Endpoint,
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
SourcePaths: uploadSourcePaths(req.Files),
|
||||
BundlePaths: uploadBundlePaths(req.Files),
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
uploadResult := UploadResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
UploadStatus: result.Status,
|
||||
}
|
||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
||||
if status.RunID != "" || status.Status != "" {
|
||||
uploadResult.RunStatus = &RunStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: redactTokenString(status.Error, token),
|
||||
}
|
||||
if status.Status != "" {
|
||||
uploadResult.Status = status.Status
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||
return uploadResult, nil
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return uploadResult, fmt.Errorf("distributor run %q failed: %s", status.RunID, uploadResult.RunStatus.Error)
|
||||
}
|
||||
return uploadResult, nil
|
||||
}
|
||||
|
||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
||||
status, err := client.Status(ctx, runID)
|
||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||
return status, err
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(statusPollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
next, err := client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status = next
|
||||
if terminalRunStatus(status.Status) {
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func terminalRunStatus(status string) bool {
|
||||
return status == "succeeded" || status == "failed"
|
||||
}
|
||||
|
||||
type distributorUploadClient struct {
|
||||
client *distributorupload.Client
|
||||
}
|
||||
|
||||
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
httpClient := (*http.Client)(nil)
|
||||
if timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return distributorUploadClient{client: client}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
files := make([]distributorbundle.BundleFile, 0, len(opts.Files))
|
||||
for _, file := range opts.Files {
|
||||
files = append(files, distributorbundle.BundleFile{
|
||||
SourcePath: file.SourcePath,
|
||||
Path: file.BundlePath,
|
||||
})
|
||||
}
|
||||
result, err := c.client.UploadFiles(ctx, distributorupload.UploadFilesOptions{
|
||||
PipelineID: opts.PipelineID,
|
||||
ID: opts.BundleID,
|
||||
Created: opts.CreatedAt,
|
||||
IdempotencyKey: opts.IdempotencyKey,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
return uploadFilesResult{}, err
|
||||
}
|
||||
return uploadFilesResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
status, err := c.client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return runStatus{}, err
|
||||
}
|
||||
return runStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: status.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type uploadErrorContext struct {
|
||||
Endpoint string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
SourcePaths []string
|
||||
BundlePaths []string
|
||||
Token string
|
||||
}
|
||||
|
||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
var conflict *distributorupload.IdempotencyConflictError
|
||||
isConflict := errors.As(err, &conflict)
|
||||
err = redactToken(err, ctx.Token)
|
||||
if isConflict {
|
||||
return &IdempotencyConflictError{
|
||||
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
||||
}
|
||||
|
||||
func uploadSourcePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.SourcePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func uploadBundlePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.BundlePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func redactToken(err error, token string) error {
|
||||
if err == nil || token == "" {
|
||||
return err
|
||||
}
|
||||
return errors.New(redactTokenString(err.Error(), token))
|
||||
}
|
||||
|
||||
func redactTokenString(value, token string) string {
|
||||
if token == "" {
|
||||
return value
|
||||
}
|
||||
return strings.ReplaceAll(value, token, "[redacted]")
|
||||
}
|
||||
406
internal/adapters/distributor/client_test.go
Normal file
406
internal/adapters/distributor/client_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN"
|
||||
cfg.Timeout = 15 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", PipelineID: "reports", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||
t.Fatalf("result = %#v, want accepted run", result)
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
||||
}
|
||||
if factory.endpoint != cfg.Endpoint {
|
||||
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||
}
|
||||
if factory.token != "secret-token" {
|
||||
t.Fatalf("factory token = %q, want secret-token", factory.token)
|
||||
}
|
||||
if factory.timeout != 15*time.Second {
|
||||
t.Fatalf("factory timeout = %s, want 15s", factory.timeout)
|
||||
}
|
||||
got := factory.client.opts
|
||||
if got.PipelineID != "weatherreporter.daily" {
|
||||
t.Fatalf("PipelineID = %q, want weatherreporter.daily", got.PipelineID)
|
||||
}
|
||||
if got.BundleID != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID)
|
||||
}
|
||||
if got.IdempotencyKey != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey)
|
||||
}
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("files = %#v, want two mappings", got.Files)
|
||||
}
|
||||
if got.Files[0].SourcePath != "/tmp/report.md" || got.Files[0].BundlePath != "2026-06-07/daily/report.md" {
|
||||
t.Fatalf("first file = %#v, want archive mapping", got.Files[0])
|
||||
}
|
||||
if got.Files[1].SourcePath != "/tmp/report.md" || got.Files[1].BundlePath != "2026-06-07/daily/latest.md" {
|
||||
t.Fatalf("second file = %#v, want latest mapping", got.Files[1])
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt is zero, want generated report timestamp")
|
||||
}
|
||||
if factory.client.statusRunID != "run-123" {
|
||||
t.Fatalf("Status runID = %q, want run-123", factory.client.statusRunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsMissingInputs(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Client, *UploadRequest)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Token",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
t.Setenv(c.TokenEnv, "")
|
||||
},
|
||||
wantErr: "token environment variable",
|
||||
},
|
||||
{
|
||||
name: "PipelineID",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.PipelineID = ""
|
||||
},
|
||||
wantErr: "pipeline id is required",
|
||||
},
|
||||
{
|
||||
name: "Files",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files = nil
|
||||
},
|
||||
wantErr: "upload files are required",
|
||||
},
|
||||
{
|
||||
name: "SourcePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].SourcePath = ""
|
||||
},
|
||||
wantErr: "source path is required",
|
||||
},
|
||||
{
|
||||
name: "BundlePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].BundlePath = ""
|
||||
},
|
||||
wantErr: "bundle path is required",
|
||||
},
|
||||
{
|
||||
name: "UploadClientFactory",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
c.newUploadClient = nil
|
||||
},
|
||||
wantErr: "upload client factory is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient)
|
||||
req := validUploadRequest()
|
||||
tt.mutate(client, &req)
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
err: fmt.Errorf("factory failed with secret-token"),
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), cfg.Endpoint) {
|
||||
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
req := validUploadRequest()
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
for _, want := range []string{cfg.Endpoint, req.PipelineID, req.BundleID, req.IdempotencyKey, req.Files[0].SourcePath, req.Files[0].BundlePath, req.Files[1].BundlePath} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want context %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsAcceptedWhenStatusLookupFails(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statusErr: fmt.Errorf("status rejected secret-token"),
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload despite status lookup failure", err)
|
||||
}
|
||||
if result.Status != "accepted" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want accepted status with status error", result)
|
||||
}
|
||||
if strings.Contains(result.StatusError, "secret-token") {
|
||||
t.Fatalf("StatusError = %q, want token redacted", result.StatusError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = 2 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statuses: []runStatus{
|
||||
{RunID: "run-123", Status: "accepted"},
|
||||
{RunID: "run-123", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
||||
}
|
||||
if factory.client.statusCalls != 2 {
|
||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsLatestStatusWhenPollingTimesOut(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = time.Millisecond
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", Status: "running"},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload with status timeout recorded", err)
|
||||
}
|
||||
if result.Status != "running" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want latest status and status timeout", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{
|
||||
RunID: "run-123",
|
||||
Status: "failed",
|
||||
Error: "destination rejected secret-token",
|
||||
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want failed distributor run error")
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
|
||||
t.Fatalf("result = %#v, want failed run status report", result)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
||||
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
err: &distributorupload.IdempotencyConflictError{
|
||||
HTTPError: distributorupload.HTTPError{
|
||||
StatusCode: 409,
|
||||
Status: "409 Conflict",
|
||||
Message: "conflicting upload for secret-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
var conflict *IdempotencyConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "idempotency conflict") {
|
||||
t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func validUploadRequest() UploadRequest {
|
||||
return UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUploadFactory struct {
|
||||
endpoint string
|
||||
token string
|
||||
timeout time.Duration
|
||||
client *fakeUploadClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
f.endpoint = endpoint
|
||||
f.token = token
|
||||
f.timeout = timeout
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
type fakeUploadClient struct {
|
||||
opts uploadFilesOptions
|
||||
statusRunID string
|
||||
statusCalls int
|
||||
result uploadFilesResult
|
||||
status runStatus
|
||||
statuses []runStatus
|
||||
err error
|
||||
statusErr error
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
c.opts = opts
|
||||
if c.err != nil {
|
||||
return uploadFilesResult{}, c.err
|
||||
}
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
c.statusRunID = runID
|
||||
c.statusCalls++
|
||||
if c.statusErr != nil {
|
||||
return runStatus{}, c.statusErr
|
||||
}
|
||||
if len(c.statuses) > 0 {
|
||||
index := c.statusCalls - 1
|
||||
if index >= len(c.statuses) {
|
||||
index = len(c.statuses) - 1
|
||||
}
|
||||
return c.statuses[index], nil
|
||||
}
|
||||
return c.status, nil
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--format", "json",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
@@ -57,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want nonzero exit error")
|
||||
@@ -85,7 +85,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -97,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--out", "/tmp/daily.md",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
@@ -130,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package weatherapi adapts the internal weather API to forecast bundles.
|
||||
// Package weatherapi adapts the internal weather API to weather data bundles.
|
||||
package weatherapi
|
||||
|
||||
import (
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -83,11 +83,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *forecast.Bundle
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation forecast.Observation
|
||||
var observation weatherdata.Observation
|
||||
if err := decodeSource(raw, &observation); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||
}
|
||||
var current forecast.Current
|
||||
var current weatherdata.Current
|
||||
if err := decodeSource(raw, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly forecast.ForecastRun
|
||||
var hourly weatherdata.ForecastRun
|
||||
if err := decodeSource(raw, &hourly); err != nil {
|
||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative forecast.ForecastRun
|
||||
var narrative weatherdata.ForecastRun
|
||||
if err := decodeSource(raw, &narrative); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -210,11 +210,11 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
var alerts weatherdata.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion forecast.Discussion
|
||||
var discussion weatherdata.Discussion
|
||||
if err := decodeSource(raw, &discussion); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||
}
|
||||
var story forecast.WeatherStory
|
||||
var story weatherdata.WeatherStory
|
||||
if err := decodeSource(raw, &story); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
source := forecast.Source{
|
||||
source := weatherdata.Source{
|
||||
Name: sourceName,
|
||||
FetchedAt: b.fetchedAt,
|
||||
Missing: true,
|
||||
@@ -276,7 +276,7 @@ func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
|
||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
||||
@@ -284,7 +284,7 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
}
|
||||
@@ -292,13 +292,13 @@ func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, requ
|
||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
|
||||
policy := b.client.policyFor(source.Name)
|
||||
if policy == config.MissingSourceError {
|
||||
return fmt.Errorf("%s: %s", source.Name, message)
|
||||
}
|
||||
if policy == config.MissingSourceWarn {
|
||||
warning := forecast.SourceWarning{
|
||||
warning := weatherdata.SourceWarning{
|
||||
Source: source.Name,
|
||||
Code: code,
|
||||
Severity: "warning",
|
||||
@@ -313,7 +313,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addSource(source forecast.Source) {
|
||||
func (b *bundleBuilder) addSource(source weatherdata.Source) {
|
||||
b.bundle.Sources = append(b.bundle.Sources, source)
|
||||
}
|
||||
|
||||
@@ -335,33 +335,33 @@ type envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.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)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
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, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
}
|
||||
|
||||
source := forecast.Source{
|
||||
source := weatherdata.Source{
|
||||
Name: sourceName,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
@@ -430,7 +430,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||
return fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
@@ -423,7 +423,7 @@ func containsPath(requested []string, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
|
||||
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
@@ -431,7 +431,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
|
||||
}
|
||||
}
|
||||
t.Fatalf("source %q not found in %#v", name, sources)
|
||||
return forecast.Source{}
|
||||
return weatherdata.Source{}
|
||||
}
|
||||
|
||||
func hashFixtureData(t *testing.T, fixture string) string {
|
||||
|
||||
@@ -3,21 +3,26 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type ReportKind string
|
||||
@@ -45,6 +50,7 @@ type GenerateRequest struct {
|
||||
Date time.Time
|
||||
StormStart time.Time
|
||||
StormEnd time.Time
|
||||
Notifier Notifier
|
||||
}
|
||||
|
||||
type BatchRequest struct {
|
||||
@@ -54,6 +60,7 @@ type BatchRequest struct {
|
||||
OutputDir string
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
}
|
||||
|
||||
type FetchBundleRequest struct {
|
||||
@@ -61,10 +68,14 @@ type FetchBundleRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type BriefingRequest struct {
|
||||
type ModuleSnapshotRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type ReportFacts struct {
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
|
||||
type ReportRequest struct {
|
||||
@@ -73,27 +84,25 @@ type ReportRequest struct {
|
||||
OutputPath string
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
}
|
||||
|
||||
type BriefingResult struct {
|
||||
Package briefing.Package
|
||||
OutputPath string
|
||||
Notifier Notifier
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
Briefing briefing.Package
|
||||
BriefingPath string
|
||||
ModuleSnapshot module.Snapshot
|
||||
ModuleSnapshotPath string
|
||||
DataPackage promptinput.Package
|
||||
DataPackagePath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
NotificationPath string
|
||||
Metadata state.Metadata
|
||||
MetadataPath string
|
||||
PriorSnapshot *state.PriorSnapshot
|
||||
RecentChanges []changes.Change
|
||||
RenderResult *scriptorium.RenderResult
|
||||
RunResult *scriptorium.RunResult
|
||||
Notification *NotificationResult
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
@@ -113,9 +122,13 @@ type BatchReportResult struct {
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
NotificationStatus string `json:"notificationStatus,omitempty"`
|
||||
NotificationRunID string `json:"notificationRunId,omitempty"`
|
||||
NotificationPipelineID string `json:"notificationPipelineId,omitempty"`
|
||||
NotificationError string `json:"notificationError,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
BriefingPath string `json:"briefingPath,omitempty"`
|
||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
@@ -139,6 +152,55 @@ type Renderer interface {
|
||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Notify(context.Context, NotificationRequest) (*NotificationResult, error)
|
||||
}
|
||||
|
||||
type NotificationRequest struct {
|
||||
ReportID report.ID
|
||||
RunID string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
ReportPath string
|
||||
BundlePaths []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type NotificationResult struct {
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
RunID string
|
||||
Status string
|
||||
UploadStatus string
|
||||
StatusError string
|
||||
PipelineID string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report []byte
|
||||
Error string
|
||||
}
|
||||
|
||||
type NotificationError struct {
|
||||
Request NotificationRequest
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *NotificationError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "notification failed"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *NotificationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
now := req.Now
|
||||
if now.IsZero() {
|
||||
@@ -153,6 +215,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
Notifier: req.Notifier,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -198,7 +261,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
for _, resolved := range resolvedReports {
|
||||
item := batchReportResult(resolved)
|
||||
if paths, err := store.Paths(resolved); err == nil {
|
||||
item.BriefingPath = paths.Briefing
|
||||
item.DataPackagePath = paths.DataPackage
|
||||
item.PreflightPath = paths.Preflight
|
||||
item.ReportPath = paths.RenderedReport
|
||||
@@ -211,19 +273,34 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
OutputPath: outputPath,
|
||||
Renderer: req.Renderer,
|
||||
Store: store,
|
||||
Notifier: req.Notifier,
|
||||
})
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
var notificationErr *NotificationError
|
||||
if errors.As(err, ¬ificationErr) {
|
||||
item.NotificationStatus = "failed"
|
||||
item.NotificationError = notificationErr.Error()
|
||||
item.NotificationPipelineID = notificationErr.Request.PipelineID
|
||||
if paths, pathErr := store.Paths(resolved); pathErr == nil {
|
||||
item.NotificationPath = paths.Notification
|
||||
}
|
||||
}
|
||||
result.Failed++
|
||||
} else {
|
||||
item.Status = "succeeded"
|
||||
item.BriefingPath = reportResult.BriefingPath
|
||||
item.DataPackagePath = reportResult.DataPackagePath
|
||||
item.PreflightPath = reportResult.PreflightPath
|
||||
item.ReportPath = reportResult.ReportPath
|
||||
item.OutputPath = reportResult.OutputPath
|
||||
item.MetadataPath = reportResult.MetadataPath
|
||||
item.NotificationPath = reportResult.NotificationPath
|
||||
if reportResult.Notification != nil {
|
||||
item.NotificationStatus = reportResult.Notification.Status
|
||||
item.NotificationRunID = reportResult.Notification.RunID
|
||||
item.NotificationPipelineID = reportResult.Notification.PipelineID
|
||||
}
|
||||
result.Succeeded++
|
||||
}
|
||||
result.Reports = append(result.Reports, item)
|
||||
@@ -263,7 +340,11 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
return registry.Resolve(id, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
Date: req.Date,
|
||||
@@ -281,12 +362,24 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return registry.BatchReports(batch, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
}
|
||||
|
||||
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
|
||||
if err != nil {
|
||||
return report.Registry{}, err
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
||||
switch kind {
|
||||
case ReportDaily:
|
||||
@@ -315,7 +408,7 @@ func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
|
||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||
client, err := weatherapi.New(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -327,7 +420,7 @@ func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle,
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
|
||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||
if req.OutputPath == "" {
|
||||
return nil, fmt.Errorf("output path is required")
|
||||
}
|
||||
@@ -341,33 +434,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := BuildBriefing(req, bundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputPath := req.OutputPath
|
||||
if outputPath == "" {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := store.Paths(req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputPath = paths.Briefing
|
||||
}
|
||||
if err := briefing.Save(outputPath, pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
}
|
||||
|
||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
@@ -390,24 +456,43 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefingPackage, err := BuildBriefing(BriefingRequest{
|
||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
}, bundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
|
||||
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
}, reportFacts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
|
||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
|
||||
|
||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
})
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||
Metadata: promptMetadata(metadata),
|
||||
Modules: moduleSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -415,6 +500,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata.DataPackagePath = dataPackagePath
|
||||
|
||||
renderer := req.Renderer
|
||||
if renderer == nil {
|
||||
@@ -439,13 +525,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: dataPackagePath,
|
||||
Preflight: preflightPath,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
})
|
||||
metadata.PreflightPath = preflightPath
|
||||
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
|
||||
if metadataErr != nil {
|
||||
return nil, metadataErr
|
||||
@@ -481,81 +561,329 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
|
||||
if notificationPath != "" {
|
||||
metadata.NotificationPath = notificationPath
|
||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
||||
if metadataErr != nil {
|
||||
return nil, metadataErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ReportResult{
|
||||
Briefing: briefingPackage,
|
||||
BriefingPath: briefingPath,
|
||||
ModuleSnapshot: moduleSnapshot,
|
||||
ModuleSnapshotPath: moduleSnapshotPath,
|
||||
DataPackage: dataPackage,
|
||||
DataPackagePath: dataPackagePath,
|
||||
PreflightPath: preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: outputPath,
|
||||
NotificationPath: notificationPath,
|
||||
Metadata: metadata,
|
||||
MetadataPath: metadataPath,
|
||||
PriorSnapshot: priorSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
RenderResult: renderResult,
|
||||
RunResult: runResult,
|
||||
Notification: notification,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
|
||||
notifier, enabled := reportNotifier(cfg, notifier)
|
||||
if !enabled {
|
||||
return nil, "", nil
|
||||
}
|
||||
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
|
||||
for _, daypart := range req.Config.Dayparts {
|
||||
notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata)
|
||||
if err != nil {
|
||||
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, NotificationRequest{}, nil, err)
|
||||
if saveErr != nil {
|
||||
return nil, "", saveErr
|
||||
}
|
||||
return nil, notificationPath, err
|
||||
}
|
||||
result, err := notifier.Notify(ctx, notificationRequest)
|
||||
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, notificationRequest, result, err)
|
||||
if saveErr != nil {
|
||||
return nil, "", saveErr
|
||||
}
|
||||
if err != nil {
|
||||
return result, notificationPath, &NotificationError{
|
||||
Request: notificationRequest,
|
||||
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
|
||||
}
|
||||
}
|
||||
return result, notificationPath, nil
|
||||
}
|
||||
|
||||
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
||||
if !cfg.Notify.Distributor.Enabled {
|
||||
return noopNotifier{}, false
|
||||
}
|
||||
if notifier != nil {
|
||||
return notifier, true
|
||||
}
|
||||
return distributorNotifier{
|
||||
client: distributoradapter.New(cfg.Notify.Distributor),
|
||||
}, true
|
||||
}
|
||||
|
||||
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
||||
values := config.DistributorTemplateValues{
|
||||
LocationID: cfg.Location.ID,
|
||||
ReportID: string(resolved.Definition.ID),
|
||||
RunID: metadata.RunID,
|
||||
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
||||
BatchOutputName: resolved.Definition.BatchOutputName,
|
||||
}
|
||||
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
values.BundleID = bundleID
|
||||
pipelineID, err := config.RenderDistributorPipelineID(cfg.Notify.Distributor.PipelineIDTemplate, values)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
idempotencyKey, err := config.RenderDistributorIdempotencyKey(cfg.Notify.Distributor.IdempotencyKeyTemplate, values)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
return NotificationRequest{
|
||||
ReportID: resolved.Definition.ID,
|
||||
RunID: metadata.RunID,
|
||||
PipelineID: pipelineID,
|
||||
BundleID: bundleID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ReportPath: reportPath,
|
||||
BundlePaths: bundlePaths,
|
||||
CreatedAt: metadata.GeneratedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := period.Start.In(location)
|
||||
end := period.End.In(location)
|
||||
values.ValidStartDate = start.Format(timeutil.DateLayout)
|
||||
values.ValidEndDate = end.Format(timeutil.DateLayout)
|
||||
values.ValidStartTime = start.Format("1504")
|
||||
values.ValidEndTime = end.Format("1504")
|
||||
values.ValidStartStamp = start.Format("2006-01-02T1504")
|
||||
values.ValidEndStamp = end.Format("2006-01-02T1504")
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveNotificationArtifact(ctx context.Context, store state.Store, resolved report.Resolved, cfg config.Config, metadata state.Metadata, req NotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("state store is required")
|
||||
}
|
||||
artifact := state.DistributorNotificationArtifact{
|
||||
SchemaVersion: state.DistributorNotificationSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
AttemptedAt: time.Now(),
|
||||
Endpoint: cfg.Notify.Distributor.Endpoint,
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
SourcePath: req.ReportPath,
|
||||
BundlePaths: append([]string(nil), req.BundlePaths...),
|
||||
BundleCreated: req.CreatedAt,
|
||||
Status: "attempted",
|
||||
}
|
||||
if result != nil {
|
||||
artifact.Status = result.Status
|
||||
artifact.Upload = &state.DistributorUploadResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.UploadStatus,
|
||||
}
|
||||
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
||||
artifact.RunStatus = &state.DistributorRunStatus{
|
||||
RunID: result.RunID,
|
||||
PipelineID: result.PipelineID,
|
||||
Status: result.Status,
|
||||
AcceptedAt: result.AcceptedAt,
|
||||
StartedAt: result.StartedAt,
|
||||
FinishedAt: result.FinishedAt,
|
||||
Report: append([]byte(nil), result.Report...),
|
||||
Error: result.Error,
|
||||
}
|
||||
}
|
||||
artifact.StatusError = result.StatusError
|
||||
}
|
||||
if notifyErr != nil {
|
||||
artifact.Status = "failed"
|
||||
artifact.Error = notifyErr.Error()
|
||||
}
|
||||
if artifact.Status == "" {
|
||||
artifact.Status = "unknown"
|
||||
}
|
||||
return store.SaveDistributorNotification(ctx, resolved, artifact)
|
||||
}
|
||||
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type distributorNotifier struct {
|
||||
client *distributoradapter.Client
|
||||
}
|
||||
|
||||
func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||
result, err := n.client.Upload(ctx, distributoradapter.UploadRequest{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
notification := &NotificationResult{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
UploadStatus: result.UploadStatus,
|
||||
StatusError: result.StatusError,
|
||||
}
|
||||
if result.RunStatus != nil {
|
||||
if result.RunStatus.PipelineID != "" {
|
||||
notification.PipelineID = result.RunStatus.PipelineID
|
||||
}
|
||||
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
||||
notification.StartedAt = result.RunStatus.StartedAt
|
||||
notification.FinishedAt = result.RunStatus.FinishedAt
|
||||
notification.Report = append([]byte(nil), result.RunStatus.Report...)
|
||||
notification.Error = result.RunStatus.Error
|
||||
}
|
||||
if err != nil {
|
||||
return notification, err
|
||||
}
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||
for _, bundlePath := range bundlePaths {
|
||||
files = append(files, distributoradapter.UploadFile{
|
||||
SourcePath: sourcePath,
|
||||
BundlePath: bundlePath,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (module.Snapshot, error) {
|
||||
reportFacts, err := BuildReportFacts(req, bundle)
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
return BuildModuleSnapshotFromFacts(req, reportFacts)
|
||||
}
|
||||
|
||||
func BuildReportFacts(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (ReportFacts, error) {
|
||||
collected := facts.BuildCollected(bundle)
|
||||
derived, err := buildDerivedFacts(req.Config, req.Resolved, collected)
|
||||
if err != nil {
|
||||
return ReportFacts{}, err
|
||||
}
|
||||
return ReportFacts{
|
||||
Collected: collected,
|
||||
Derived: derived,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportFacts) (module.Snapshot, error) {
|
||||
if !req.Resolved.ValidPeriod.IsValid() {
|
||||
return module.Snapshot{}, fmt.Errorf("resolved valid period is required")
|
||||
}
|
||||
registry, err := briefing.DefaultModuleRegistry()
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
moduleContext := briefing.ModuleContext{
|
||||
Resolved: req.Resolved,
|
||||
Collected: reportFacts.Collected,
|
||||
Derived: reportFacts.Derived,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}
|
||||
var outputs []module.Output
|
||||
for _, item := range req.Resolved.Definition.Modules {
|
||||
definition, err := registry.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
continue
|
||||
}
|
||||
output, err := registry.BuildModule(moduleContext, item)
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
if output == nil {
|
||||
continue
|
||||
}
|
||||
outputs = append(outputs, *output)
|
||||
}
|
||||
return module.NewSnapshot(outputs)
|
||||
}
|
||||
|
||||
func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) briefing.BuildContext {
|
||||
return briefing.BuildContext{
|
||||
Resolved: resolved,
|
||||
Bundle: collected.Bundle(),
|
||||
Units: cfg.WeatherAPI.Units,
|
||||
Timezone: cfg.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func promptMetadata(metadata state.Metadata) promptinput.Metadata {
|
||||
return promptinput.Metadata{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: metadata.Variant,
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: metadata.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
SourceWarnings: metadata.SourceWarnings,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDerivedFacts(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) (facts.DerivedFacts, error) {
|
||||
dayparts := make([]forecast.DaypartDefinition, 0, len(cfg.Dayparts))
|
||||
for _, daypart := range cfg.Dayparts {
|
||||
dayparts = append(dayparts, forecast.DaypartDefinition{
|
||||
Name: daypart.Name,
|
||||
Start: daypart.Start,
|
||||
End: daypart.End,
|
||||
})
|
||||
}
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return briefing.BuildDaily(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summary)
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
if req.Resolved.Definition.ID == report.Weekend {
|
||||
return briefing.BuildWeekend(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
case report.Storm:
|
||||
return briefing.BuildStorm(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
return facts.BuildDerived(facts.BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: cfg.WeatherAPI.Timezone,
|
||||
Dayparts: dayparts,
|
||||
Collected: collected,
|
||||
})
|
||||
default:
|
||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||
@@ -575,11 +903,11 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
return state.NewFilesystemStore(cfg.Workspace)
|
||||
}
|
||||
|
||||
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
if priorSnapshot == nil {
|
||||
return nil, nil
|
||||
}
|
||||
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
|
||||
previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -589,7 +917,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
}
|
||||
switch current.Metadata.ReportID {
|
||||
switch reportID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestFetchAndSaveBundle(t *testing.T) {
|
||||
@@ -76,67 +78,6 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
||||
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||
}
|
||||
if result.OutputPath != path {
|
||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
||||
}
|
||||
if result.Package.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read briefing artifact: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) {
|
||||
t.Fatalf("briefing artifact missing expected fields:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
||||
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
@@ -164,12 +105,18 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
runBody: "# Daily Report\n\nRain this morning.\n",
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
store := &recordingStore{Store: filesystemStore}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -193,36 +140,47 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||
}
|
||||
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
||||
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
||||
}
|
||||
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read module snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
|
||||
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
|
||||
}
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
|
||||
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
||||
}
|
||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
||||
!strings.Contains(string(data), "recent_changes:") ||
|
||||
!strings.Contains(string(data), "current_conditions:") ||
|
||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||
}
|
||||
var savedDataPackage struct {
|
||||
Report struct {
|
||||
CurrentLocalDate string `json:"currentLocalDate"`
|
||||
} `json:"report"`
|
||||
Briefing briefing.Package `json:"briefing"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
|
||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("decode data package: %v", err)
|
||||
}
|
||||
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
||||
}
|
||||
location := savedDataPackage.Briefing.Metadata.Location
|
||||
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("data package location = %#v, want configured prompt location", location)
|
||||
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
||||
t.Fatal("data package metadata stanza missing")
|
||||
}
|
||||
current := savedDataPackage.Briefing.CurrentConditions
|
||||
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
||||
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
||||
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
|
||||
if !ok || current["condition_text"] != "Clear" {
|
||||
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
||||
}
|
||||
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
||||
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||
}
|
||||
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||
@@ -237,7 +195,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if result.Metadata.RunID != resolved.Metadata().RunID {
|
||||
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
|
||||
}
|
||||
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
||||
if result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
||||
}
|
||||
if result.Metadata.RenderedReportPath != result.ReportPath {
|
||||
@@ -255,6 +213,262 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{
|
||||
result: &NotificationResult{
|
||||
RunID: "distributor-run",
|
||||
Status: "succeeded",
|
||||
UploadStatus: "accepted",
|
||||
PipelineID: "reports",
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
},
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if result.Notification == nil {
|
||||
t.Fatal("Notification = nil, want notification result")
|
||||
}
|
||||
if result.Notification.RunID != "distributor-run" || result.Notification.Status != "succeeded" {
|
||||
t.Fatalf("Notification = %#v, want succeeded distributor run", result.Notification)
|
||||
}
|
||||
if result.NotificationPath == "" || result.Metadata.NotificationPath != result.NotificationPath {
|
||||
t.Fatalf("NotificationPath result=%q metadata=%q, want linked artifact", result.NotificationPath, result.Metadata.NotificationPath)
|
||||
}
|
||||
notificationData, err := os.ReadFile(result.NotificationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read notification artifact: %v", err)
|
||||
}
|
||||
var notificationArtifact state.DistributorNotificationArtifact
|
||||
if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil {
|
||||
t.Fatalf("decode notification artifact: %v", err)
|
||||
}
|
||||
wantBundlePaths := []string{
|
||||
"2026-05-29/daily/2026-05-29-daily-" + result.Metadata.RunID + ".md",
|
||||
}
|
||||
if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact)
|
||||
}
|
||||
if len(notifier.requests) != 1 {
|
||||
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
|
||||
}
|
||||
req := notifier.requests[0]
|
||||
if req.ReportPath != result.ReportPath {
|
||||
t.Fatalf("notification ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
|
||||
}
|
||||
if req.ReportPath == outputPath {
|
||||
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
|
||||
}
|
||||
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
||||
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
|
||||
}
|
||||
if req.PipelineID != "weatherreporter.daily" {
|
||||
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
|
||||
}
|
||||
if req.BundleID != "weatherreporter.home.daily_today" {
|
||||
t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
|
||||
}
|
||||
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
|
||||
t.Fatalf("IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
|
||||
}
|
||||
if req.RunID != result.Metadata.RunID {
|
||||
t.Fatalf("notification RunID = %q, want report run id %q", req.RunID, result.Metadata.RunID)
|
||||
}
|
||||
if !req.CreatedAt.Equal(result.Metadata.GeneratedAt) {
|
||||
t.Fatalf("notification CreatedAt = %s, want generated at %s", req.CreatedAt, result.Metadata.GeneratedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{err: errors.New("upload rejected")}
|
||||
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want notification error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") {
|
||||
t.Fatalf("error = %q, want notification context", err.Error())
|
||||
}
|
||||
if len(notifier.requests) != 1 {
|
||||
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
|
||||
}
|
||||
paths, pathErr := store.Paths(resolved)
|
||||
if pathErr != nil {
|
||||
t.Fatalf("Paths() error = %v", pathErr)
|
||||
}
|
||||
notificationData, readErr := os.ReadFile(paths.Notification)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read notification artifact after failure: %v", readErr)
|
||||
}
|
||||
var notification state.DistributorNotificationArtifact
|
||||
if err := json.Unmarshal(notificationData, ¬ification); err != nil {
|
||||
t.Fatalf("decode notification artifact: %v", err)
|
||||
}
|
||||
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") {
|
||||
t.Fatalf("notification failure artifact = %+v, want failed upload context", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
renderer Renderer
|
||||
}{
|
||||
{
|
||||
name: "Render",
|
||||
renderer: &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"},
|
||||
err: errors.New("render failed"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Run",
|
||||
renderer: &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"},
|
||||
runErr: errors.New("run failed"),
|
||||
runBody: "# Daily Report\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: tt.renderer,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want generation error")
|
||||
}
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want fetch setup error")
|
||||
}
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none after fetch failure", notifier.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
@@ -379,25 +593,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorDailyBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -462,14 +658,18 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant)
|
||||
if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" {
|
||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
|
||||
}
|
||||
if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate)
|
||||
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
|
||||
if err != nil {
|
||||
t.Fatalf("decode daily summary: %v", err)
|
||||
}
|
||||
if result.Briefing.Daily.Planning == nil {
|
||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||
if !ok || dailySummary["date"] != "2026-05-30" {
|
||||
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||
t.Fatal("tomorrow_planning stanza missing")
|
||||
}
|
||||
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
||||
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
||||
@@ -494,25 +694,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorDailyBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -561,25 +743,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorThreeDayBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
@@ -603,11 +767,12 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.ThreeDay == nil {
|
||||
t.Fatal("ThreeDay = nil")
|
||||
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
t.Fatalf("decode daypart summaries: %v", err)
|
||||
}
|
||||
if len(result.Briefing.ThreeDay.Days) != 3 {
|
||||
t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days))
|
||||
if !ok || len(dayparts) == 0 {
|
||||
t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
|
||||
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
|
||||
@@ -637,25 +802,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorWeekendBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportWeekend,
|
||||
@@ -679,11 +826,12 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
t.Fatalf("decode daypart summaries: %v", err)
|
||||
}
|
||||
if len(result.Briefing.Weekend.Days) != 3 {
|
||||
t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days))
|
||||
if !ok || len(dayparts) == 0 {
|
||||
t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
|
||||
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
|
||||
@@ -728,14 +876,11 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.Storm == nil {
|
||||
t.Fatal("Storm = nil")
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.storm_report" {
|
||||
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
|
||||
}
|
||||
if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 {
|
||||
t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods))
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
|
||||
t.Fatal("precip_timing stanza missing")
|
||||
}
|
||||
if _, err := os.Stat(outputPath); err != nil {
|
||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
||||
@@ -744,7 +889,7 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
|
||||
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
|
||||
t.Fatalf("data package missing storm content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -788,15 +933,15 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("InspectMetadata() error = %v", err)
|
||||
}
|
||||
if metadata.BriefingPath != result.BriefingPath || metadata.DataPackagePath != result.DataPackagePath {
|
||||
if metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
|
||||
}
|
||||
briefingPackage, err := InspectBriefing(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
||||
moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectBriefing() error = %v", err)
|
||||
t.Fatalf("InspectModules() error = %v", err)
|
||||
}
|
||||
if briefingPackage.Metadata.RunID != result.Metadata.RunID {
|
||||
t.Fatalf("briefing RunID = %q, want %q", briefingPackage.Metadata.RunID, result.Metadata.RunID)
|
||||
if moduleSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(moduleSnapshot.Outputs) == 0 {
|
||||
t.Fatalf("module snapshot = %#v, want persisted outputs", moduleSnapshot)
|
||||
}
|
||||
dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
||||
if err != nil {
|
||||
@@ -898,6 +1043,45 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
reports:
|
||||
tomorrow:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- alert_digest
|
||||
- tomorrow_planning
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
now := mustParse("2026-05-29T18:00:00-05:00")
|
||||
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportTomorrow,
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning}
|
||||
if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") {
|
||||
t.Fatalf("ModuleIDs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func moduleIDsForTest(ids []module.ID) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, string(id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1008,6 +1192,69 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
notifier := &recordingNotifier{
|
||||
errByReport: map[report.ID]error{
|
||||
report.ThreeDay: errors.New("distributor unavailable"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if len(notifier.requests) != 3 {
|
||||
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
|
||||
}
|
||||
var failedThreeDay bool
|
||||
for _, item := range result.Reports {
|
||||
if item.ReportID == report.ThreeDay {
|
||||
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
|
||||
failedThreeDay = true
|
||||
}
|
||||
if item.NotificationStatus != "failed" {
|
||||
t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationPipelineID != "weatherreporter.three-day" {
|
||||
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID)
|
||||
}
|
||||
if !strings.Contains(item.NotificationError, "distributor unavailable") {
|
||||
t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if item.Status != "succeeded" {
|
||||
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
||||
}
|
||||
if item.NotificationStatus != "accepted" {
|
||||
t.Fatalf("report %s notification status = %q, want accepted", item.ReportID, item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationPipelineID == "" {
|
||||
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
|
||||
}
|
||||
}
|
||||
if !failedThreeDay {
|
||||
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchUsesOutputDirectory(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
@@ -1063,13 +1310,79 @@ func assertPathsExist(t *testing.T, paths ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||
low := 50.0
|
||||
high := 58.0
|
||||
precip := 10.0
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
|
||||
t.Helper()
|
||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot {
|
||||
t.Helper()
|
||||
low := 50
|
||||
high := 58
|
||||
precip := 10
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
|
||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||
"low_temp_f": low,
|
||||
"high_temp_f": high,
|
||||
"max_pop_percent": precip,
|
||||
}},
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
"morning": map[string]any{
|
||||
"period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)},
|
||||
"temp_range_f": "50-58",
|
||||
},
|
||||
}},
|
||||
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
|
||||
"max_pop_percent": precip,
|
||||
"max_pop_time": "6 AM",
|
||||
}},
|
||||
{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
||||
t.Helper()
|
||||
precip := 10
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
date + "_morning": map[string]any{
|
||||
"period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")},
|
||||
"temp_range_f": "50-58",
|
||||
"max_pop_percent": precip,
|
||||
"max_pop_time": "6 AM",
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||
return briefing.Metadata{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
Variant: "today",
|
||||
@@ -1078,77 +1391,6 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Daily: &briefing.Daily{
|
||||
BottomLine: briefing.BottomLine{
|
||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||
MaxPrecipProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
||||
},
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "morning"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
|
||||
high := 58.0
|
||||
precip := 10.0
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
PromptID: resolved.Definition.PromptID,
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
ThreeDay: &briefing.ThreeDay{
|
||||
Days: []briefing.OutlookDay{
|
||||
{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: &high},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func priorWeekendBriefing(resolved report.Resolved) briefing.Package {
|
||||
high := 58.0
|
||||
precip := 10.0
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
PromptID: resolved.Definition.PromptID,
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Weekend: &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: &high},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: resolved.ValidPeriod.Start.Add(12 * time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,6 +1406,44 @@ type recordingRenderer struct {
|
||||
runBody string
|
||||
}
|
||||
|
||||
type recordingStore struct {
|
||||
state.Store
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||
s.calls = append(s.calls, "module_snapshot")
|
||||
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
||||
}
|
||||
|
||||
func (s *recordingStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
|
||||
s.calls = append(s.calls, "data_package")
|
||||
return s.Store.SaveDataPackage(ctx, resolved, pkg)
|
||||
}
|
||||
|
||||
func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Resolved, artifact state.PreflightArtifact) (string, error) {
|
||||
s.calls = append(s.calls, "preflight")
|
||||
return s.Store.SavePreflight(ctx, resolved, artifact)
|
||||
}
|
||||
|
||||
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
|
||||
s.calls = append(s.calls, "prepare_report")
|
||||
return s.Store.PrepareRenderedReport(ctx, resolved)
|
||||
}
|
||||
|
||||
func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
|
||||
s.calls = append(s.calls, "metadata")
|
||||
return s.Store.SaveMetadata(ctx, metadata)
|
||||
}
|
||||
|
||||
func successfulRenderer(body string) *recordingRenderer {
|
||||
return &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||
runBody: body,
|
||||
}
|
||||
}
|
||||
|
||||
type selectiveRenderer struct {
|
||||
renderCalls int
|
||||
runCalls int
|
||||
@@ -1171,6 +1451,43 @@ type selectiveRenderer struct {
|
||||
runBody string
|
||||
}
|
||||
|
||||
type recordingNotifier struct {
|
||||
requests []NotificationRequest
|
||||
result *NotificationResult
|
||||
err error
|
||||
errByReport map[report.ID]error
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||
n.requests = append(n.requests, req)
|
||||
if err := n.errByReport[req.ReportID]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.err != nil {
|
||||
return nil, n.err
|
||||
}
|
||||
if n.result != nil {
|
||||
result := *n.result
|
||||
if result.BundleID == "" {
|
||||
result.BundleID = req.BundleID
|
||||
}
|
||||
if result.IdempotencyKey == "" {
|
||||
result.IdempotencyKey = req.IdempotencyKey
|
||||
}
|
||||
if result.PipelineID == "" {
|
||||
result.PipelineID = req.PipelineID
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
return &NotificationResult{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Status: "accepted",
|
||||
UploadStatus: "accepted",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
||||
r.renderCalls++
|
||||
if req.PromptID == r.failRenderPrompt {
|
||||
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type InspectReportsRequest struct {
|
||||
@@ -28,7 +29,7 @@ type SourceInspection struct {
|
||||
ReportID report.ID `json:"reportId"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
||||
@@ -44,12 +45,12 @@ func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata
|
||||
return inspection.metadata, err
|
||||
}
|
||||
|
||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
|
||||
269
internal/briefing/base_modules.go
Normal file
269
internal/briefing/base_modules.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type MetadataModule struct {
|
||||
RunID string `json:"run_id"`
|
||||
ReportID report.ID `json:"report_id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
type SourceWarningSummary struct {
|
||||
Source string `json:"source"`
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsModule struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
||||
}
|
||||
|
||||
type AlertDigestModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
RelevantCount int `json:"relevant_count"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||
}
|
||||
|
||||
type AlertSummary struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type AreaForecastDiscussionModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
KeyMessages []string `json:"key_messages,omitempty"`
|
||||
ShortTerm string `json:"short_term,omitempty"`
|
||||
LongTerm string `json:"long_term,omitempty"`
|
||||
}
|
||||
|
||||
type WeatherStoryModule struct {
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
value := MetadataModule{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
||||
}
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
current := ctx.Collected.Current
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value := CurrentConditionsModule{
|
||||
ConditionText: current.ConditionText,
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
||||
if value == nil {
|
||||
value = &AlertDigestModule{}
|
||||
}
|
||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||
}
|
||||
|
||||
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||
discussion := ctx.Collected.Discussion
|
||||
if discussion == nil {
|
||||
return nil, nil
|
||||
}
|
||||
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||
}
|
||||
sections, err := areaForecastDiscussionSections(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := AreaForecastDiscussionModule{}
|
||||
if sections["product"] {
|
||||
value.Product = discussion.Product
|
||||
}
|
||||
if sections["key_messages"] {
|
||||
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||
}
|
||||
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||
value.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if sections["long_term"] && discussion.LongTerm != nil {
|
||||
value.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||
}
|
||||
|
||||
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||
if len(options.Sections) == 0 {
|
||||
return map[string]bool{
|
||||
"product": true,
|
||||
"key_messages": true,
|
||||
"short_term": true,
|
||||
"long_term": true,
|
||||
}, nil
|
||||
}
|
||||
sections := map[string]bool{}
|
||||
for _, section := range options.Sections {
|
||||
switch section {
|
||||
case "product", "key_messages", "short_term", "long_term":
|
||||
sections[section] = true
|
||||
default:
|
||||
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
story := ctx.Collected.WeatherStory
|
||||
if story == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value := WeatherStoryModule{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
StartTime: story.StartTime,
|
||||
EndTime: story.EndTime,
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||
}
|
||||
|
||||
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
out = append(out, SourceWarningSummary{
|
||||
Source: warning.Source,
|
||||
Code: warning.Code,
|
||||
Severity: warning.Severity,
|
||||
Message: warning.Message,
|
||||
CompletenessImpact: warning.CompletenessImpact,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||
if collected.Alerts == nil && !missing {
|
||||
return nil
|
||||
}
|
||||
value := &AlertDigestModule{Missing: missing}
|
||||
if collected.Alerts != nil {
|
||||
value.Checked = true
|
||||
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||
}
|
||||
value.RelevantCount = len(overlaps)
|
||||
for _, overlap := range overlaps {
|
||||
value.Relevant = append(value.Relevant, AlertSummary{
|
||||
Event: overlap.Event,
|
||||
Headline: overlap.Headline,
|
||||
Severity: overlap.Severity,
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||
for _, source := range sources {
|
||||
if source.Name == name && source.Missing {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v CurrentConditionsModule) isEmpty() bool {
|
||||
return v.ConditionText == "" &&
|
||||
v.IsDay == nil &&
|
||||
v.TemperatureC == nil &&
|
||||
v.TemperatureF == nil &&
|
||||
v.ApparentTemperatureC == nil &&
|
||||
v.ApparentTemperatureF == nil &&
|
||||
v.DewpointC == nil &&
|
||||
v.DewpointF == nil &&
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == nil &&
|
||||
v.WindSpeedMph == nil &&
|
||||
v.WindDirectionDegrees == nil
|
||||
}
|
||||
296
internal/briefing/base_modules_test.go
Normal file
296
internal/briefing/base_modules_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
tests := []struct {
|
||||
id module.ID
|
||||
stanza string
|
||||
}{
|
||||
{id: module.Metadata, stanza: "metadata"},
|
||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{id: module.WeatherStory, stanza: "weather_story"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil {
|
||||
t.Fatal("BuildModule() output = nil, want stanza")
|
||||
}
|
||||
if output.ID != tt.id || output.StanzaName != tt.stanza {
|
||||
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[MetadataModule](t, output)
|
||||
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("metadata = %#v, want report identity", value)
|
||||
}
|
||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Location = %#v, want configured location", value.Location)
|
||||
}
|
||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||
}
|
||||
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
|
||||
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal metadata: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[CurrentConditionsModule](t, output)
|
||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal current conditions: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Alerts = &weatherdata.AlertRun{}
|
||||
ctx.Derived.AlertOverlaps = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(checked empty) error = %v", err)
|
||||
}
|
||||
checked := moduleValue[AlertDigestModule](t, output)
|
||||
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
|
||||
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
|
||||
}
|
||||
|
||||
ctx.Collected.Alerts = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(missing) error = %v", err)
|
||||
}
|
||||
missing := moduleValue[AlertDigestModule](t, output)
|
||||
if missing.Checked || !missing.Missing {
|
||||
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Current = nil
|
||||
ctx.Collected.Discussion = nil
|
||||
ctx.Collected.WeatherStory = nil
|
||||
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(afd) error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("AFD = %#v, want discussion sections", afd)
|
||||
}
|
||||
|
||||
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(weather story) error = %v", err)
|
||||
}
|
||||
story := moduleValue[WeatherStoryModule](t, storyOutput)
|
||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||
}
|
||||
data, err := json.Marshal(storyOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal weather story: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "download_url") {
|
||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func testModuleContext() ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||
resolved := report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
}
|
||||
isDay := true
|
||||
tempF := 74.0
|
||||
apparentF := 76.0
|
||||
humidity := 71.0
|
||||
windMph := 8.0
|
||||
windDirection := 190.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
Collected: facts.CollectedFacts{
|
||||
Current: &weatherdata.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: &isDay,
|
||||
TemperatureF: &tempF,
|
||||
ApparentTemperatureF: &apparentF,
|
||||
RelativeHumidityPercent: &humidity,
|
||||
WindSpeedMph: &windMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
Discussion: &weatherdata.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Scattered showers are possible."},
|
||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
|
||||
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
|
||||
},
|
||||
WeatherStory: &weatherdata.WeatherStory{
|
||||
OfficeID: "LSX",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Title: "Rain Chances",
|
||||
Description: "Scattered showers are possible.",
|
||||
AltText: "Weather story graphic with rain chances.",
|
||||
Priority: true,
|
||||
Order: 1,
|
||||
DownloadURL: "https://example.invalid/story.png",
|
||||
},
|
||||
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
|
||||
SourceWarnings: []weatherdata.SourceWarning{{
|
||||
Source: "daily",
|
||||
Code: "missing_source",
|
||||
Severity: "warning",
|
||||
Message: "daily source is missing",
|
||||
Endpoint: "/forecast/daily",
|
||||
CompletenessImpact: "source omitted",
|
||||
}},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
AlertOverlaps: []forecast.AlertOverlap{{
|
||||
Event: "Flood Watch",
|
||||
Headline: "Flooding possible",
|
||||
Severity: "Moderate",
|
||||
}},
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mustParseModuleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
bundle := loadBundleFixture(t)
|
||||
currentIsDay := true
|
||||
currentTemp := 75.9
|
||||
currentFeelsLike := 76.1
|
||||
currentHumidity := 56.0
|
||||
currentWind := 10.7
|
||||
bundle.Current = &forecast.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: ¤tIsDay,
|
||||
TemperatureF: ¤tTemp,
|
||||
ApparentTemperatureF: ¤tFeelsLike,
|
||||
RelativeHumidityPercent: ¤tHumidity,
|
||||
WindSpeedMph: ¤tWind,
|
||||
}
|
||||
bundle.Sources[0].DataSHA256 = "abc123"
|
||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Bundle: bundle,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.SchemaVersion != SchemaVersion {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
|
||||
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
|
||||
}
|
||||
if pkg.Metadata.ReportID != report.DailyToday {
|
||||
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||
}
|
||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||
}
|
||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 {
|
||||
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
|
||||
}
|
||||
if pkg.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
if len(pkg.Daily.Dayparts) != 5 {
|
||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if len(pkg.Daily.NarrativePeriods) != 1 {
|
||||
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
|
||||
}
|
||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||
}
|
||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||
}
|
||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary == "" {
|
||||
t.Fatal("BottomLine summary is empty")
|
||||
}
|
||||
if _, err := json.Marshal(pkg); err != nil {
|
||||
t.Fatalf("briefing package is not JSON inspectable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||
}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Sources: []forecast.Source{
|
||||
{Name: "hourly", FetchedAt: time.Now()},
|
||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||
},
|
||||
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||
}
|
||||
if pkg.CurrentConditions != nil {
|
||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if pkg.Metadata.Alerts == nil {
|
||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||
}
|
||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal alert metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"missing"`) {
|
||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := loadBundleFixture(t)
|
||||
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
|
||||
}}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve tomorrow: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
wind := 34.0
|
||||
summary := &forecast.DailySummary{
|
||||
Date: "2026-05-30",
|
||||
Period: resolved.ValidPeriod,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "overnight",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: 40,
|
||||
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "morning",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: wind,
|
||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Snow: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
||||
}
|
||||
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
||||
}
|
||||
if pkg.Daily.Planning == nil {
|
||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||
}
|
||||
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBriefingPackage(t *testing.T) {
|
||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
||||
if err := Save(path, pkg); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read briefing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), SchemaVersion) {
|
||||
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func loadBundleFixture(t *testing.T) *forecast.Bundle {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read bundle fixture: %v", err)
|
||||
}
|
||||
var bundle forecast.Bundle
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("decode bundle fixture: %v", err)
|
||||
}
|
||||
return &bundle
|
||||
}
|
||||
|
||||
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve daily: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func defaultDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
|
||||
return forecast.ForecastPeriod{
|
||||
StartTime: mustParse(start),
|
||||
EndTime: mustParse(end),
|
||||
TextDescription: "Clear",
|
||||
TemperatureF: &temperature,
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
330
internal/briefing/derived_modules.go
Normal file
330
internal/briefing/derived_modules.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type DerivedDailySummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopWindow string `json:"max_pop_window,omitempty"`
|
||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
}
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
type PrecipTimingModule struct {
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
}
|
||||
|
||||
type OutdoorWindowsModule struct {
|
||||
Best *OutdoorWindowModule `json:"best,omitempty"`
|
||||
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
||||
}
|
||||
|
||||
type OutdoorWindowModule struct {
|
||||
Daypart string `json:"daypart"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type TomorrowPlanningModule struct {
|
||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return nil, fmt.Errorf("daily summary facts are required")
|
||||
}
|
||||
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||
return nil, fmt.Errorf("daypart summary facts are required")
|
||||
}
|
||||
value := map[string]DerivedDaypartSummaryModule{}
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
key := daypartKey(daypart, prefixDates)
|
||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||
value := OutdoorWindowsModule{
|
||||
Best: outdoorWindowValue(windows.Best),
|
||||
Worst: outdoorWindowValue(windows.Worst),
|
||||
}
|
||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||
}
|
||||
|
||||
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
||||
}
|
||||
planning := buildTomorrowPlanning(summary)
|
||||
value := TomorrowPlanningModule{}
|
||||
if planning != nil {
|
||||
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||
}
|
||||
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
||||
}
|
||||
|
||||
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||
value := DerivedDailySummaryModule{
|
||||
Date: summary.Date,
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
hazards := map[string]struct{}{}
|
||||
var temperature forecast.Range
|
||||
var apparent forecast.Range
|
||||
var maxPop *forecast.TimedValue
|
||||
var maxGust *forecast.TimedValue
|
||||
var maxPopWindow timeutil.Period
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&temperature, daypart.Temperature)
|
||||
addRange(&apparent, daypart.ApparentTemperature)
|
||||
if daypart.MaxPrecipitationProbability != nil {
|
||||
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
|
||||
copied := *daypart.MaxPrecipitationProbability
|
||||
maxPop = &copied
|
||||
maxPopWindow = daypart.Period
|
||||
}
|
||||
}
|
||||
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
value.HighTempF = roundedInt(temperature.Max)
|
||||
value.LowTempF = roundedInt(temperature.Min)
|
||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||
if maxPop != nil {
|
||||
value.MaxPopPercent = roundedInt(&maxPop.Value)
|
||||
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
|
||||
}
|
||||
if maxGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||
}
|
||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
||||
value.DominantConditions = sortedSet(conditions)
|
||||
value.Hazards = sortedSet(hazards)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Period: daypart.Period,
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
}
|
||||
if daypart.PeakWindGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
||||
value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned}
|
||||
if timing.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||
}
|
||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
||||
return value
|
||||
}
|
||||
|
||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
return &OutdoorWindowModule{
|
||||
Daypart: window.Daypart,
|
||||
Start: window.Start,
|
||||
End: window.End,
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
}
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
seen[summary.Date] = struct{}{}
|
||||
}
|
||||
return len(seen) > 1
|
||||
}
|
||||
|
||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
key := normalizedKey(daypart.Name)
|
||||
if key == "" {
|
||||
key = "unnamed"
|
||||
}
|
||||
if !prefixDate {
|
||||
return key
|
||||
}
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||
}
|
||||
|
||||
func normalizedKey(value string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
var out strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range lower {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
out.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
out.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(out.String(), "_")
|
||||
}
|
||||
|
||||
func rangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low != nil && high != nil && *low == *high {
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", *low, *high)
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
return fmt.Sprintf("%d", *high)
|
||||
}
|
||||
|
||||
func daypartApparentRangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
return rangeLabel(value)
|
||||
}
|
||||
|
||||
func roundedInt(value *float64) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
rounded := int(*value + 0.5)
|
||||
if *value < 0 {
|
||||
rounded = int(*value - 0.5)
|
||||
}
|
||||
return &rounded
|
||||
}
|
||||
|
||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return clockLabel(value.Time, timezone)
|
||||
}
|
||||
|
||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func clockLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
label := value.In(location).Format("3 PM")
|
||||
if label == "12 AM" && value.In(location).Minute() == 0 {
|
||||
return "12 AM"
|
||||
}
|
||||
return label
|
||||
}
|
||||
240
internal/briefing/derived_modules_test.go
Normal file
240
internal/briefing/derived_modules_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
|
||||
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
||||
}
|
||||
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
||||
}
|
||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
}
|
||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily summary: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"high_temp_f", "low_temp_f", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "qpf") {
|
||||
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||
}
|
||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
|
||||
t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy)
|
||||
}
|
||||
|
||||
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||
}
|
||||
dry := moduleValue[PrecipTimingModule](t, output)
|
||||
if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
|
||||
t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry)
|
||||
}
|
||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||
|
||||
morning, ok := value["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart keys = %#v, want configured morning key", value)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if !overnight.Cold {
|
||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
||||
|
||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
|
||||
if outdoor.Best == nil || outdoor.Worst == nil {
|
||||
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
|
||||
}
|
||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||
}
|
||||
|
||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
|
||||
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
|
||||
}
|
||||
data, err := json.Marshal(planningOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tomorrow planning: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
|
||||
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
|
||||
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
|
||||
}
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
|
||||
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
|
||||
}
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
windows := moduleValue[OutdoorWindowsModule](t, output)
|
||||
if windows.Best != nil || windows.Worst != nil {
|
||||
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func derivedModuleContext(id report.ID) ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(id)
|
||||
summary := forecast.DailySummary{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
|
||||
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
|
||||
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
|
||||
},
|
||||
}
|
||||
hours := []weatherdata.ForecastPeriod{
|
||||
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
||||
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
||||
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||
}
|
||||
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: summary.Period,
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}
|
||||
}
|
||||
|
||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: mustParseModuleTime(start),
|
||||
End: mustParseModuleTime(end),
|
||||
}, []weatherdata.ForecastPeriod{hour})
|
||||
}
|
||||
|
||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||
startTime := mustParseModuleTime(start)
|
||||
endTime := startTime.Add(time.Hour)
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
ApparentTemperatureF: apparent,
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(precip),
|
||||
WindGustMph: floatPtr(gust),
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
298
internal/briefing/modules.go
Normal file
298
internal/briefing/modules.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type ModuleContext struct {
|
||||
Resolved report.Resolved
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||
|
||||
type ModuleDefinition struct {
|
||||
ID module.ID
|
||||
StanzaName string
|
||||
DefaultOptions any
|
||||
RequiredCollected []module.FactRequirement
|
||||
RequiredDerived []module.FactRequirement
|
||||
SupportedReports []report.ID
|
||||
MissingData module.MissingDataBehavior
|
||||
AllowDuplicate bool
|
||||
Builder ModuleBuilder
|
||||
}
|
||||
|
||||
type ModuleRegistry struct {
|
||||
definitions map[module.ID]ModuleDefinition
|
||||
}
|
||||
|
||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||
return NewModuleRegistry(defaultModuleDefinitions())
|
||||
}
|
||||
|
||||
func MustDefaultModuleRegistry() ModuleRegistry {
|
||||
registry, err := DefaultModuleRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, definition := range definitions {
|
||||
if definition.ID == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
||||
}
|
||||
if definition.StanzaName == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
||||
}
|
||||
if _, ok := registry.definitions[definition.ID]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
||||
}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = definition.ID
|
||||
registry.definitions[definition.ID] = definition
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
||||
definition, ok := r.definitions[id]
|
||||
if !ok {
|
||||
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||
}
|
||||
options := item.Options
|
||||
if options == nil {
|
||||
options = definition.DefaultOptions
|
||||
}
|
||||
output, err := definition.Builder(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if output.ID != definition.ID {
|
||||
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
||||
}
|
||||
if output.StanzaName != definition.StanzaName {
|
||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||
seenModules := map[module.ID]struct{}{}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, item := range items {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
||||
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
||||
}
|
||||
seenModules[item.ID] = struct{}{}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = item.ID
|
||||
if !definition.SupportsReport(reportID) {
|
||||
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
||||
if len(d.SupportedReports) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, supported := range d.SupportedReports {
|
||||
if supported == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
if d.DefaultOptions == nil {
|
||||
return fmt.Errorf("module %q does not accept options", d.ID)
|
||||
}
|
||||
want := reflect.TypeOf(d.DefaultOptions)
|
||||
got := reflect.TypeOf(options)
|
||||
if got == want {
|
||||
return nil
|
||||
}
|
||||
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
DefaultOptions: module.MetadataOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildMetadataModule,
|
||||
},
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: "current_conditions",
|
||||
DefaultOptions: module.CurrentConditionsOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDaypartSummaries,
|
||||
StanzaName: "derived_daypart_summaries",
|
||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
},
|
||||
{
|
||||
ID: module.HourlyTable,
|
||||
StanzaName: "hourly_table",
|
||||
DefaultOptions: module.HourlyTableOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
SupportedReports: []report.ID{report.Storm},
|
||||
MissingData: module.MissingDataError,
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: "alert_digest",
|
||||
DefaultOptions: module.AlertDigestOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
DefaultOptions: module.WeatherStoryOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildWeatherStoryModule,
|
||||
},
|
||||
{
|
||||
ID: module.ForecastDelta,
|
||||
StanzaName: "forecast_delta",
|
||||
DefaultOptions: module.ForecastDeltaOptions{},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
},
|
||||
{
|
||||
ID: module.OutdoorWindows,
|
||||
StanzaName: "outdoor_windows",
|
||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.DailyTomorrow},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTomorrowPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeekendPlanning,
|
||||
StanzaName: "weekend_planning",
|
||||
DefaultOptions: module.WeekendPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Weekend},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
},
|
||||
{
|
||||
ID: module.StormWindowSummary,
|
||||
StanzaName: "storm_window_summary",
|
||||
DefaultOptions: module.StormWindowSummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedStormWindowSummary},
|
||||
SupportedReports: []report.ID{report.Storm},
|
||||
MissingData: module.MissingDataError,
|
||||
},
|
||||
}
|
||||
}
|
||||
76
internal/briefing/modules_test.go
Normal file
76
internal/briefing/modules_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||
t.Fatalf("error = %v, want unknown module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.Metadata},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.StormWindowSummary}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "storm_window_summary" is not compatible with report "daily_today"`) {
|
||||
t.Fatalf("error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||
t.Fatalf("error = %v, want invalid option shape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
// Package briefing builds report-specific structured briefing packages.
|
||||
// Package briefing builds prompt-facing module values.
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
Storm *Storm `json:"storm,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
@@ -36,7 +22,7 @@ type Metadata struct {
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
@@ -47,21 +33,6 @@ type LocationContext struct {
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsContext struct {
|
||||
ConditionText string `json:"conditionText,omitempty"`
|
||||
IsDay *bool `json:"isDay,omitempty"`
|
||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
||||
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
||||
DewpointC *float64 `json:"dewpointC,omitempty"`
|
||||
DewpointF *float64 `json:"dewpointF,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
||||
}
|
||||
|
||||
type SourceMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
@@ -70,7 +41,7 @@ type SourceMetadata struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type AlertStatus struct {
|
||||
@@ -82,7 +53,7 @@ type AlertStatus struct {
|
||||
|
||||
type BuildContext struct {
|
||||
Resolved report.Resolved
|
||||
Bundle *forecast.Bundle
|
||||
Bundle *weatherdata.Bundle
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||
return Metadata{
|
||||
SchemaVersion: SchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
func buildPackage(ctx BuildContext) Package {
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
CurrentConditions: currentConditions(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func copyLocation(location *LocationContext) *LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
@@ -125,42 +88,6 @@ func copyLocation(location *LocationContext) *LocationContext {
|
||||
return &copied
|
||||
}
|
||||
|
||||
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||
if bundle == nil || bundle.Current == nil {
|
||||
return nil
|
||||
}
|
||||
current := bundle.Current
|
||||
context := CurrentConditionsContext{
|
||||
ConditionText: current.ConditionText,
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||
}
|
||||
if context.ConditionText == "" &&
|
||||
context.IsDay == nil &&
|
||||
context.TemperatureC == nil &&
|
||||
context.TemperatureF == nil &&
|
||||
context.ApparentTemperatureC == nil &&
|
||||
context.ApparentTemperatureF == nil &&
|
||||
context.DewpointC == nil &&
|
||||
context.DewpointF == nil &&
|
||||
context.RelativeHumidityPercent == nil &&
|
||||
context.WindSpeedKmh == nil &&
|
||||
context.WindSpeedMph == nil &&
|
||||
context.WindDirectionDegrees == nil {
|
||||
return nil
|
||||
}
|
||||
return &context
|
||||
}
|
||||
|
||||
func copyBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -177,18 +104,19 @@ func copyFloat(value *float64) *float64 {
|
||||
return &copied
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save briefing package: %w", err)
|
||||
}
|
||||
func copyTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||
if bundle == nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
if run == nil {
|
||||
continue
|
||||
}
|
||||
@@ -199,7 +127,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -219,14 +147,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
||||
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return bundle.Warnings
|
||||
}
|
||||
|
||||
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -247,16 +175,6 @@ func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||
return status
|
||||
}
|
||||
|
||||
func setRelevantAlertCount(metadata *Metadata, count int) {
|
||||
if metadata.Alerts == nil {
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
metadata.Alerts = &AlertStatus{}
|
||||
}
|
||||
metadata.Alerts.RelevantCount = count
|
||||
}
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Storm struct {
|
||||
TimingWindow timeutil.Period `json:"timingWindow"`
|
||||
EventHeadlines []string `json:"eventHeadlines,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
|
||||
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
|
||||
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
|
||||
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
|
||||
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
|
||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Storm {
|
||||
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
|
||||
}
|
||||
if ctx.Bundle == nil {
|
||||
return Package{}, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
period := ctx.Resolved.ValidPeriod
|
||||
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
|
||||
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
|
||||
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
|
||||
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
|
||||
summary := forecast.SummarizeDaypart("storm window", period, hourly)
|
||||
summary.AlertOverlaps = alerts
|
||||
|
||||
storm := &Storm{
|
||||
TimingWindow: period,
|
||||
EventHeadlines: stormHeadlines(alerts),
|
||||
Hazards: stormHazards(alerts, summary),
|
||||
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
|
||||
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
|
||||
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
|
||||
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
|
||||
RelevantAlerts: alerts,
|
||||
HourlyPeriods: hourly,
|
||||
DailyPeriods: daily,
|
||||
NarrativePeriods: narrative,
|
||||
WindowSummary: summary,
|
||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Storm = storm
|
||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||
var headlines []string
|
||||
for _, alert := range alerts {
|
||||
if alert.Headline != "" {
|
||||
headlines = appendUnique(headlines, alert.Headline)
|
||||
continue
|
||||
}
|
||||
if alert.Event != "" {
|
||||
headlines = appendUnique(headlines, alert.Event)
|
||||
}
|
||||
}
|
||||
if len(headlines) == 0 {
|
||||
return []string{"No active alert headline overlaps the selected storm window."}
|
||||
}
|
||||
return headlines
|
||||
}
|
||||
|
||||
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
||||
hazards := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(summary.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
|
||||
hazards["precipitation"] = struct{}{}
|
||||
}
|
||||
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
|
||||
hazards["wind"] = struct{}{}
|
||||
}
|
||||
out := sortedSet(hazards)
|
||||
if len(out) == 0 {
|
||||
return []string{"No storm-specific hazard signal stands out in the selected source data."}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
|
||||
var items []string
|
||||
if summary.DominantCondition != "" {
|
||||
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil {
|
||||
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
|
||||
}
|
||||
if summary.PeakWindGust != nil {
|
||||
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
|
||||
}
|
||||
for _, period := range narrative {
|
||||
if period.TextDescription != "" {
|
||||
items = append(items, "Narrative guidance: "+period.TextDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 && len(hourly) > 0 {
|
||||
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No active storm signal is evident from the selected forecast window.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
||||
var items []string
|
||||
for _, alert := range alerts {
|
||||
label := alert.Event
|
||||
if label == "" {
|
||||
label = alert.Headline
|
||||
}
|
||||
if label != "" {
|
||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
||||
}
|
||||
}
|
||||
if summary.Indicators.Wind {
|
||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||
}
|
||||
if summary.Indicators.Snow || summary.Indicators.Ice {
|
||||
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
||||
var items []string
|
||||
if bundle == nil {
|
||||
return []string{"No source bundle was available for confidence context."}
|
||||
}
|
||||
if bundle.Discussion != nil {
|
||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||
}
|
||||
}
|
||||
if bundle.WeatherStory != nil {
|
||||
if bundle.WeatherStory.Title != "" {
|
||||
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
||||
} else {
|
||||
items = appendUnique(items, "Weather story source is available.")
|
||||
}
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
|
||||
var items []string
|
||||
if len(alerts) > 0 {
|
||||
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil {
|
||||
items = append(items, "Watch precipitation timing and probability trends.")
|
||||
}
|
||||
if summary.PeakWindGust != nil {
|
||||
items = append(items, "Watch wind gust trends.")
|
||||
}
|
||||
if bundle != nil && bundle.Discussion != nil {
|
||||
items = append(items, "Watch the next forecast discussion update for confidence changes.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
|
||||
}
|
||||
return appendUnique(nil, items...)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
precip := 80.0
|
||||
gust := 42.0
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
TextDescription: "Severe thunderstorms and gusty wind",
|
||||
ProbabilityOfPrecipitationPercent: &precip,
|
||||
WindGustMph: &gust,
|
||||
}}},
|
||||
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
TextDescription: "Storms likely.",
|
||||
}}},
|
||||
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
TextDescription: "Damaging wind possible in stronger storms.",
|
||||
}}},
|
||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Storms may intensify quickly."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||
},
|
||||
WeatherStory: &forecast.WeatherStory{
|
||||
OfficeID: "LSX",
|
||||
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
||||
Title: "Storm Risk",
|
||||
Description: "Strong storms are possible.",
|
||||
AltText: "Weather story graphic showing storm risk.",
|
||||
Order: 1,
|
||||
},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Storm {
|
||||
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Storm == nil {
|
||||
t.Fatal("Storm = nil")
|
||||
}
|
||||
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
|
||||
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
|
||||
}
|
||||
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
|
||||
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
|
||||
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
|
||||
}
|
||||
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
|
||||
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
|
||||
}
|
||||
if pkg.Storm.WeatherStory == nil {
|
||||
t.Fatal("WeatherStory = nil, want available story context")
|
||||
}
|
||||
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
||||
}
|
||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||
}
|
||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if len(pkg.Storm.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
|
||||
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
|
||||
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
|
||||
}
|
||||
if len(pkg.Storm.MostLikelyScenario) == 0 {
|
||||
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStormBriefingQuietWindow(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
|
||||
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
|
||||
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
|
||||
}
|
||||
}
|
||||
@@ -5,120 +5,27 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Daily struct {
|
||||
BottomLine BottomLine `json:"bottomLine"`
|
||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
ForecastSummaryDate string `json:"forecastSummaryDate"`
|
||||
}
|
||||
|
||||
type BottomLine struct {
|
||||
Summary string `json:"summary"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
||||
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
||||
}
|
||||
|
||||
type OutdoorWindows struct {
|
||||
Best *OutdoorWindow `json:"best,omitempty"`
|
||||
Worst *OutdoorWindow `json:"worst,omitempty"`
|
||||
Best *OutdoorWindow
|
||||
Worst *OutdoorWindow
|
||||
}
|
||||
|
||||
type OutdoorWindow struct {
|
||||
Daypart string `json:"daypart"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Daypart string
|
||||
Start string
|
||||
End string
|
||||
Reasons []string
|
||||
Score float64
|
||||
}
|
||||
|
||||
type TomorrowPlanning struct {
|
||||
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
||||
}
|
||||
|
||||
type DiscussionContext struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
KeyMessages []string `json:"keyMessages,omitempty"`
|
||||
ShortTerm string `json:"shortTerm,omitempty"`
|
||||
LongTerm string `json:"longTerm,omitempty"`
|
||||
}
|
||||
|
||||
type WeatherStoryContext struct {
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"officeId,omitempty"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"altText,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
|
||||
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
|
||||
}
|
||||
if summary == nil {
|
||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Daily = &Daily{
|
||||
BottomLine: buildBottomLine(summary),
|
||||
Dayparts: summary.Dayparts,
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
NarrativePeriods: summary.NarrativePeriods,
|
||||
Discussion: buildDiscussion(summary.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
ForecastSummaryDate: summary.Date,
|
||||
}
|
||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
|
||||
bottomLine := BottomLine{}
|
||||
conditions := map[string]struct{}{}
|
||||
hazards := map[string]struct{}{}
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&bottomLine.Temperature, daypart.Temperature)
|
||||
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
bottomLine.Hazards = sortedSet(hazards)
|
||||
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
|
||||
return bottomLine
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OvernightChangeWatch []string
|
||||
}
|
||||
|
||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
@@ -250,51 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
||||
if discussion == nil {
|
||||
return DiscussionContext{}
|
||||
}
|
||||
ctx := DiscussionContext{
|
||||
Product: discussion.Product,
|
||||
KeyMessages: discussion.KeyMessages,
|
||||
}
|
||||
if discussion.ShortTerm != nil {
|
||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if discussion.LongTerm != nil {
|
||||
ctx.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||
if bundle == nil || bundle.WeatherStory == nil {
|
||||
return nil
|
||||
}
|
||||
story := bundle.WeatherStory
|
||||
return &WeatherStoryContext{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
StartTime: story.StartTime,
|
||||
EndTime: story.EndTime,
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
}
|
||||
|
||||
func copyTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
score := 0.0
|
||||
reasons := []string{}
|
||||
@@ -335,20 +197,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
}
|
||||
}
|
||||
|
||||
func bottomLineText(conditions []string, hazards []string) string {
|
||||
if len(conditions) == 0 && len(hazards) == 0 {
|
||||
return "Quiet weather is expected."
|
||||
}
|
||||
parts := []string{}
|
||||
if len(conditions) > 0 {
|
||||
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
|
||||
}
|
||||
if len(hazards) > 0 {
|
||||
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
|
||||
}
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||
var hazards []string
|
||||
if indicators.Snow {
|
||||
@@ -1,128 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type ThreeDay struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type OutlookDay struct {
|
||||
Date string `json:"date"`
|
||||
Period timeutil.Period `json:"period"`
|
||||
OverallCharacter string `json:"overallCharacter"`
|
||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
||||
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
||||
Risks []string `json:"risks,omitempty"`
|
||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||
}
|
||||
|
||||
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.ThreeDay {
|
||||
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.ThreeDay = &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, day := range days {
|
||||
for _, alert := range day.RelevantAlerts {
|
||||
key := alert.Event
|
||||
if key == "" {
|
||||
key = alert.Headline
|
||||
}
|
||||
if key != "" {
|
||||
alerts[key] = alert
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(alerts))
|
||||
for key := range alerts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, alerts[key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
||||
day := OutlookDay{
|
||||
Date: summary.Date,
|
||||
Period: summary.Period,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
Dayparts: summary.Dayparts,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
risks := map[string]struct{}{}
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&day.Temperature, daypart.Temperature)
|
||||
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, risk := range hazardsForIndicators(daypart.Indicators) {
|
||||
risks[risk] = struct{}{}
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
risks["precipitation"] = struct{}{}
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
risks["wind"] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
risks[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
day.Risks = sortedSet(risks)
|
||||
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
|
||||
return day
|
||||
}
|
||||
|
||||
func outlookCharacter(conditions []string, risks []string) string {
|
||||
if len(conditions) == 0 && len(risks) == 0 {
|
||||
return "Quiet weather is expected."
|
||||
}
|
||||
parts := []string{}
|
||||
if len(conditions) > 0 {
|
||||
parts = append(parts, strings.Join(conditions, "; "))
|
||||
}
|
||||
if len(risks) > 0 {
|
||||
parts = append(parts, "risks: "+strings.Join(risks, "; "))
|
||||
}
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve 3-day: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 35.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "morning",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Unsettled stretch."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildThreeDay(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildThreeDay() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.ThreeDay {
|
||||
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.ThreeDay == nil {
|
||||
t.Fatal("ThreeDay = nil")
|
||||
}
|
||||
if len(pkg.ThreeDay.Days) != 2 {
|
||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||
}
|
||||
first := pkg.ThreeDay.Days[0]
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||
}
|
||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Weekend struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
Planning WeekendPlanning `json:"planning"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type WeekendPlanning struct {
|
||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
||||
}
|
||||
|
||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Weekend = &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
||||
planning := WeekendPlanning{}
|
||||
for _, day := range days {
|
||||
if day.OutdoorWindows.Best != nil {
|
||||
window := *day.OutdoorWindows.Best
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
||||
}
|
||||
if day.OutdoorWindows.Worst != nil {
|
||||
window := *day.OutdoorWindows.Worst
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
||||
}
|
||||
for _, daypart := range day.Dayparts {
|
||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
||||
}
|
||||
}
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
||||
if discussion.ShortTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
||||
}
|
||||
if discussion.LongTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
||||
}
|
||||
if bundle != nil {
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(planning.RainStormTiming) == 0 {
|
||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.ComfortConcerns) == 0 {
|
||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.UncertaintyInputs) == 0 {
|
||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return planning
|
||||
}
|
||||
|
||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.Indicators.Heat {
|
||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.Indicators.Cold {
|
||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendWindowLabel(date string, daypart string) string {
|
||||
if daypart == "" {
|
||||
return date
|
||||
}
|
||||
return strings.TrimSpace(date + " " + daypart)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weekend: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 32.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "afternoon",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
TextDescription: "Showers and thunderstorms",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Timing may shift."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildWeekend(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWeekend() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Weekend {
|
||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
}
|
||||
if len(pkg.Weekend.Days) != 1 {
|
||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
||||
}
|
||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
// Package changes compares structured briefing snapshots.
|
||||
// Package changes compares structured module snapshots.
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -25,83 +27,168 @@ type Change struct {
|
||||
Current string `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Daily == nil {
|
||||
return nil, fmt.Errorf("previous daily briefing is required")
|
||||
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daily summary: %w", err)
|
||||
}
|
||||
if current.Daily == nil {
|
||||
return nil, fmt.Errorf("current daily briefing is required")
|
||||
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daily summary: %w", err)
|
||||
}
|
||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
||||
}
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||
}
|
||||
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var changes []Change
|
||||
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
|
||||
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
|
||||
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
|
||||
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
|
||||
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
|
||||
if previousHasTiming && currentHasTiming {
|
||||
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||
}
|
||||
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
|
||||
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
|
||||
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
|
||||
sortChanges(changes)
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
|
||||
var changes []Change
|
||||
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
|
||||
changes = append(changes, Change{
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
|
||||
Previous: fmt.Sprintf("%.0f", *previous.Min),
|
||||
Current: fmt.Sprintf("%.0f", *current.Min),
|
||||
})
|
||||
}
|
||||
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
|
||||
changes = append(changes, Change{
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
|
||||
Previous: fmt.Sprintf("%.0f", *previous.Max),
|
||||
Current: fmt.Sprintf("%.0f", *current.Max),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
type dailySummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
}
|
||||
|
||||
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
|
||||
type daypartSummaryStanza struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
}
|
||||
|
||||
type precipTimingStanza struct {
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
}
|
||||
|
||||
type alertDigestStanza struct {
|
||||
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
|
||||
}
|
||||
|
||||
type alertSummaryStanza struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
}
|
||||
|
||||
type indicators struct {
|
||||
Snow bool
|
||||
Ice bool
|
||||
}
|
||||
|
||||
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
value, ok, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
if !ok {
|
||||
return value, fmt.Errorf("stanza %q is required", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
var changes []Change
|
||||
previousCategory := precipitationCategory(previous.Value)
|
||||
currentCategory := precipitationCategory(current.Value)
|
||||
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
|
||||
changes = append(changes, Change{
|
||||
Type: "precip_probability_change",
|
||||
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
|
||||
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
|
||||
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
|
||||
})
|
||||
}
|
||||
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
|
||||
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
|
||||
changes = append(changes, Change{
|
||||
Type: "precip_timing_shift",
|
||||
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
|
||||
Previous: clock(previous.Time),
|
||||
Current: clock(current.Time),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
|
||||
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
|
||||
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: "wind_gust_change",
|
||||
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
|
||||
Previous: fmt.Sprintf("%.0f mph", previous.Value),
|
||||
Current: fmt.Sprintf("%.0f mph", current.Value),
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
|
||||
Previous: fmt.Sprintf("%d", *previous),
|
||||
Current: fmt.Sprintf("%d", *current),
|
||||
}}
|
||||
}
|
||||
|
||||
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
|
||||
func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
previousCategory := precipitationCategory(float64(*previous))
|
||||
currentCategory := precipitationCategory(float64(*current))
|
||||
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||
return nil
|
||||
}
|
||||
changeType := prefix + "precip_probability_change"
|
||||
return []Change{{
|
||||
Type: changeType,
|
||||
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
|
||||
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
|
||||
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
|
||||
}}
|
||||
}
|
||||
|
||||
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
|
||||
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
|
||||
return nil
|
||||
}
|
||||
previousTime, previousOK := parseClock(previous)
|
||||
currentTime, currentOK := parseClock(current)
|
||||
if !previousOK || !currentOK {
|
||||
return nil
|
||||
}
|
||||
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: prefix + "precip_timing_shift",
|
||||
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
}}
|
||||
}
|
||||
|
||||
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: prefix + "wind_gust_change",
|
||||
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
|
||||
Previous: fmt.Sprintf("%d mph", *previous),
|
||||
Current: fmt.Sprintf("%d mph", *current),
|
||||
}}
|
||||
}
|
||||
|
||||
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
|
||||
previousSet := alertSet(previous)
|
||||
currentSet := alertSet(current)
|
||||
var changes []Change
|
||||
@@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
||||
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
@@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
if item.previous == item.current {
|
||||
continue
|
||||
}
|
||||
changeType := item.name + "_risk_change"
|
||||
changeType := prefix + item.name + "_risk_change"
|
||||
if item.current {
|
||||
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
||||
} else {
|
||||
@@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
return changes
|
||||
}
|
||||
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
|
||||
out := indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
out.Snow = out.Snow || daypart.Snow
|
||||
out.Ice = out.Ice || daypart.Ice
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
||||
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
event := alert.Event
|
||||
@@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
|
||||
return math.Abs(current-previous) >= threshold
|
||||
}
|
||||
|
||||
func clock(t time.Time) string {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
func sortChanges(items []Change) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Type == items[j].Type {
|
||||
@@ -197,3 +280,33 @@ func sortChanges(items []Change) {
|
||||
return items[i].Type < items[j].Type
|
||||
})
|
||||
}
|
||||
|
||||
func parseClock(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseTempRange(value string) (*int, *int) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(value, "-")
|
||||
if len(parts) == 1 {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
|
||||
return &parsed, &parsed
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
|
||||
if minErr != nil || maxErr != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &minValue, &maxValue
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -22,8 +23,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -35,8 +36,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -48,8 +49,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -61,8 +62,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -73,26 +74,48 @@ func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
|
||||
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
||||
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
|
||||
_, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
|
||||
if err == nil {
|
||||
t.Fatal("CompareDaily() error = nil, want missing stanza error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "derived_daily_summary") {
|
||||
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
|
||||
t.Helper()
|
||||
relevant := make([]alertSummaryStanza, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
||||
relevant = append(relevant, alertSummaryStanza{Event: alert})
|
||||
}
|
||||
return briefing.Package{
|
||||
Daily: &briefing.Daily{
|
||||
BottomLine: briefing.BottomLine{
|
||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||
MaxPrecipProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: precipTime,
|
||||
},
|
||||
},
|
||||
RelevantAlerts: alertOverlaps,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "morning", Indicators: indicators},
|
||||
},
|
||||
},
|
||||
return snapshot(t,
|
||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
||||
Date: "2026-05-29",
|
||||
HighTempF: &high,
|
||||
LowTempF: &low,
|
||||
MaxPopPercent: &precip,
|
||||
}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
"morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
|
||||
}},
|
||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot(outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func period(start string, end string) timeutil.Period {
|
||||
return timeutil.Period{Start: at(start), End: at(end)}
|
||||
}
|
||||
|
||||
func testThresholds() Thresholds {
|
||||
|
||||
@@ -2,77 +2,147 @@ package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("previous 3-day briefing is required")
|
||||
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
||||
}
|
||||
if current.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("current 3-day briefing is required")
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||
}
|
||||
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
|
||||
currentDays := outlookDaysByDate(current.ThreeDay.Days)
|
||||
previousDays := outlookDaysFromDayparts(previousDayparts)
|
||||
currentDays := outlookDaysFromDayparts(currentDayparts)
|
||||
return compareOutlookDays(previousDays, currentDays, thresholds, "")
|
||||
}
|
||||
|
||||
type outlookDay struct {
|
||||
Date string
|
||||
LowTempF *int
|
||||
HighTempF *int
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
MaxWindGustMph *int
|
||||
Indicators indicators
|
||||
}
|
||||
|
||||
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
|
||||
var changes []Change
|
||||
for date, currentDay := range currentDays {
|
||||
previousDay, ok := previousDays[date]
|
||||
if !ok {
|
||||
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
||||
changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
||||
continue
|
||||
}
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
|
||||
}
|
||||
for date := range previousDays {
|
||||
if _, ok := currentDays[date]; !ok {
|
||||
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
||||
changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
||||
}
|
||||
}
|
||||
sortChanges(changes)
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change {
|
||||
func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) {
|
||||
for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = prefix + "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = prefix + "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) {
|
||||
for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
changes = append(changes, change)
|
||||
changes[len(changes)-1].Type = "outlook_" + change.Type
|
||||
}
|
||||
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) {
|
||||
for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) {
|
||||
for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
|
||||
out := map[string]briefing.OutlookDay{}
|
||||
for _, day := range days {
|
||||
date := day.Date
|
||||
if date == "" && !day.Period.Start.IsZero() {
|
||||
date = day.Period.Start.Format(time.DateOnly)
|
||||
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
|
||||
out := map[string]outlookDay{}
|
||||
var keys []string
|
||||
for key := range dayparts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if date != "" {
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
daypart := dayparts[key]
|
||||
date := daypartDate(daypart)
|
||||
if date == "" {
|
||||
continue
|
||||
}
|
||||
day := out[date]
|
||||
day.Date = date
|
||||
low, high := parseTempRange(daypart.TempRangeF)
|
||||
day.LowTempF = minInt(day.LowTempF, low)
|
||||
day.HighTempF = maxInt(day.HighTempF, high)
|
||||
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
|
||||
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
|
||||
day.MaxPopTime = daypart.MaxPopTime
|
||||
}
|
||||
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
|
||||
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
|
||||
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
|
||||
out[date] = day
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func daypartDate(daypart daypartSummaryStanza) string {
|
||||
if !daypart.Period.Start.IsZero() {
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func minInt(a *int, b *int) *int {
|
||||
if a == nil {
|
||||
return copyInt(b)
|
||||
}
|
||||
if b != nil && *b < *a {
|
||||
return copyInt(b)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func maxInt(a *int, b *int) *int {
|
||||
if a == nil {
|
||||
return copyInt(b)
|
||||
}
|
||||
if b != nil && *b > *a {
|
||||
return copyInt(b)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func copyInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
@@ -2,41 +2,13 @@ package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previousPrecip := 20.0
|
||||
currentPrecip := 70.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: previousPrecip,
|
||||
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: currentPrecip,
|
||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
|
||||
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
|
||||
|
||||
changes, err := CompareThreeDay(previous, current, Thresholds{
|
||||
TemperatureDegrees: 5,
|
||||
@@ -50,17 +22,20 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||
}
|
||||
var foundPrecip bool
|
||||
var foundSnow bool
|
||||
for _, change := range changes {
|
||||
if change.Type == "outlook_precip_probability_change" {
|
||||
foundPrecip = true
|
||||
}
|
||||
if change.Type == "outlook_snow_risk_change" {
|
||||
foundSnow = true
|
||||
}
|
||||
}
|
||||
if !foundPrecip || !foundSnow {
|
||||
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
|
||||
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
|
||||
t.Helper()
|
||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
date + "_morning": {
|
||||
Period: period(date+"T06:00:00Z", date+"T10:00:00Z"),
|
||||
TempRangeF: tempRange,
|
||||
MaxPopPercent: &precip,
|
||||
MaxPopTime: precipTime,
|
||||
Snow: snow,
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -3,24 +3,17 @@ package changes
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Weekend == nil {
|
||||
return nil, fmt.Errorf("previous weekend briefing is required")
|
||||
}
|
||||
if current.Weekend == nil {
|
||||
return nil, fmt.Errorf("current weekend briefing is required")
|
||||
}
|
||||
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
|
||||
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
|
||||
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
|
||||
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
|
||||
}
|
||||
for i := range changes {
|
||||
changes[i].Type = "weekend_" + changes[i].Type
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
|
||||
}
|
||||
return changes, nil
|
||||
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
|
||||
}
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
|
||||
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
|
||||
|
||||
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
||||
if err != nil {
|
||||
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want weekend changes")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ Usage:
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
@@ -118,8 +118,8 @@ var inspectRunCommands = []inspectRunCommand{
|
||||
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectMetadata(ctx, req)
|
||||
}},
|
||||
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectBriefing(ctx, req)
|
||||
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectModules(ctx, req)
|
||||
}},
|
||||
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectDataPackage(ctx, req)
|
||||
@@ -352,11 +352,21 @@ func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
||||
return
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
notificationFields := ""
|
||||
if item.NotificationStatus != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationRunID != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||
}
|
||||
if item.NotificationError != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||
}
|
||||
if item.Status == "failed" {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "generate daily") {
|
||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||
}
|
||||
removedInspectCommand := "inspect " + "briefing"
|
||||
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHelpShortFlag(t *testing.T) {
|
||||
@@ -86,12 +90,12 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
|
||||
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") {
|
||||
t.Fatalf("data package output missing storm content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -122,12 +126,12 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) {
|
||||
if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
||||
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
||||
}
|
||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||
@@ -160,7 +164,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
@@ -206,7 +210,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
@@ -217,7 +221,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) {
|
||||
if !strings.Contains(string(data), "id: three_day") || !strings.Contains(string(data), "derived_daypart_summaries:") {
|
||||
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -252,7 +256,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
@@ -263,7 +267,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) {
|
||||
if !strings.Contains(string(data), "id: weekend") || !strings.Contains(string(data), "derived_daypart_summaries:") {
|
||||
t.Fatalf("data package output missing weekend content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -289,7 +293,7 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob weekend packages: %v", err)
|
||||
}
|
||||
@@ -333,11 +337,11 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
|
||||
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
|
||||
}
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob weekend packages: %v", err)
|
||||
}
|
||||
@@ -346,6 +350,88 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
Total: 2,
|
||||
Succeeded: 1,
|
||||
Failed: 1,
|
||||
Reports: []app.BatchReportResult{
|
||||
{
|
||||
ReportID: "daily_today",
|
||||
Status: "succeeded",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
NotificationStatus: "accepted",
|
||||
NotificationRunID: "distributor-run-1",
|
||||
},
|
||||
{
|
||||
ReportID: "three_day",
|
||||
Status: "failed",
|
||||
Error: "notify report three_day: upload failed",
|
||||
NotificationStatus: "failed",
|
||||
NotificationError: "notify report three_day: upload failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
|
||||
var decoded app.BatchResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decoded.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" {
|
||||
t.Fatalf("success notification fields = %#v", decoded.Reports[0])
|
||||
}
|
||||
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") {
|
||||
t.Fatalf("failure notification fields = %#v", decoded.Reports[1])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) {
|
||||
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
Total: 1,
|
||||
Failed: 1,
|
||||
Reports: []app.BatchReportResult{
|
||||
{
|
||||
ReportID: "daily_today",
|
||||
Status: "failed",
|
||||
Error: "notify report daily_today: upload failed: [redacted]",
|
||||
NotificationStatus: "failed",
|
||||
NotificationError: "notify report daily_today: upload failed: [redacted]",
|
||||
},
|
||||
},
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
|
||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
||||
t.Fatalf("output contains token value:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "[redacted]") {
|
||||
t.Fatalf("output missing redacted marker:\n%s", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
@@ -384,6 +470,108 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/runs/distributor-run-1" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.daily","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/v1/pipelines/weatherreporter.daily/upload" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","status":"accepted"}`))
|
||||
}))
|
||||
t.Cleanup(distributorServer.Close)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"run", "evening",
|
||||
"--config", configPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||
}
|
||||
if len(summary.Reports) != 1 {
|
||||
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
||||
}
|
||||
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.daily" {
|
||||
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||
t.Fatalf("stderr missing notification fields:\n%s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") {
|
||||
t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
|
||||
}))
|
||||
t.Cleanup(distributorServer.Close)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"run", "evening",
|
||||
"--config", configPath,
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want notification failure")
|
||||
}
|
||||
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||
}
|
||||
if len(summary.Reports) != 1 || summary.Reports[0].NotificationStatus != "failed" {
|
||||
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports)
|
||||
}
|
||||
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} {
|
||||
if strings.Contains(output, "cli-secret-token") {
|
||||
t.Fatalf("output contains token value:\n%s", output)
|
||||
}
|
||||
}
|
||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||
if !strings.Contains(output, "[redacted]") {
|
||||
t.Fatalf("output missing redaction marker:\n%s", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
@@ -405,11 +593,11 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json"))
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json"))
|
||||
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob 3-day packages: %v", err)
|
||||
}
|
||||
@@ -450,7 +638,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
@@ -461,26 +649,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") {
|
||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||
}
|
||||
var decoded struct {
|
||||
Briefing struct {
|
||||
Metadata struct {
|
||||
Location struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"location"`
|
||||
} `json:"metadata"`
|
||||
} `json:"briefing"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode data package: %v", err)
|
||||
}
|
||||
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
||||
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
||||
if !strings.Contains(string(data), "location:") ||
|
||||
!strings.Contains(string(data), "id: home") ||
|
||||
!strings.Contains(string(data), "name: Brentwood") ||
|
||||
!strings.Contains(string(data), "region: St. Louis Metro") ||
|
||||
!strings.Contains(string(data), "timezone: UTC") {
|
||||
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
|
||||
}
|
||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||
if err != nil {
|
||||
@@ -527,14 +704,14 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run(generate) error = %v", err)
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json")
|
||||
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
|
||||
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
|
||||
@@ -545,7 +722,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
for _, command := range []string{"metadata", "briefing", "data-package", "sources"} {
|
||||
var sourcesOutput string
|
||||
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
@@ -554,9 +732,12 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), runID) {
|
||||
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
|
||||
}
|
||||
if command == "sources" {
|
||||
sourcesOutput = stdout.String()
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"warnings"`) {
|
||||
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(sourcesOutput, `"warnings"`) {
|
||||
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,7 +769,7 @@ func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
|
||||
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
|
||||
|
||||
for _, command := range commands {
|
||||
t.Run(command+" requires run id", func(t *testing.T) {
|
||||
|
||||
@@ -2,24 +2,36 @@
|
||||
// precedence, and validation.
|
||||
package config
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MissingSourcePolicy string
|
||||
type NotifyFailurePolicy string
|
||||
|
||||
const (
|
||||
MissingSourceError MissingSourcePolicy = "error"
|
||||
MissingSourceWarn MissingSourcePolicy = "warn"
|
||||
MissingSourceNone MissingSourcePolicy = "none"
|
||||
|
||||
NotifyFailureError NotifyFailurePolicy = "error"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||
Location LocationConfig `yaml:"location"`
|
||||
Secrets SecretsConfig `yaml:"secrets"`
|
||||
Notify NotifyConfig `yaml:"notify"`
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||
Reports map[string]ReportConfig `yaml:"reports"`
|
||||
}
|
||||
|
||||
type WeatherAPIConfig struct {
|
||||
@@ -37,6 +49,26 @@ type LocationConfig struct {
|
||||
Region string `yaml:"region"`
|
||||
}
|
||||
|
||||
type SecretsConfig struct {
|
||||
Directory string `yaml:"directory"`
|
||||
}
|
||||
|
||||
type NotifyConfig struct {
|
||||
Distributor DistributorNotifyConfig `yaml:"distributor"`
|
||||
}
|
||||
|
||||
type DistributorNotifyConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
||||
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||
ReportPathTemplates []string `yaml:"report_path_templates"`
|
||||
}
|
||||
|
||||
type MissingSourceConfig struct {
|
||||
Default MissingSourcePolicy `yaml:"default"`
|
||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||
@@ -56,6 +88,7 @@ type WorkspaceConfig struct {
|
||||
ReportsDir string `yaml:"reports_dir"`
|
||||
DataPackagesDir string `yaml:"data_packages_dir"`
|
||||
PreflightDir string `yaml:"preflight_dir"`
|
||||
NotificationsDir string `yaml:"notifications_dir"`
|
||||
}
|
||||
|
||||
type DaypartConfig struct {
|
||||
@@ -70,3 +103,71 @@ type RecentChangeConfig struct {
|
||||
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
|
||||
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
|
||||
}
|
||||
|
||||
type ReportConfig struct {
|
||||
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
||||
deterministicModulesSet bool
|
||||
}
|
||||
|
||||
type ModuleConfigItem struct {
|
||||
ID module.ID `yaml:"id"`
|
||||
Options any `yaml:"options,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("report entry must be a mapping")
|
||||
}
|
||||
for i := 0; i < len(value.Content); i += 2 {
|
||||
key := value.Content[i].Value
|
||||
node := value.Content[i+1]
|
||||
switch key {
|
||||
case "deterministic_modules":
|
||||
if err := node.Decode(&c.DeterministicModules); err != nil {
|
||||
return err
|
||||
}
|
||||
c.deterministicModulesSet = true
|
||||
default:
|
||||
return fmt.Errorf("unknown report entry field %q", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
||||
switch value.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if value.Value == "" {
|
||||
return fmt.Errorf("module id is required")
|
||||
}
|
||||
m.ID = module.ID(value.Value)
|
||||
return nil
|
||||
case yaml.MappingNode:
|
||||
var sawID bool
|
||||
for i := 0; i < len(value.Content); i += 2 {
|
||||
key := value.Content[i].Value
|
||||
node := value.Content[i+1]
|
||||
switch key {
|
||||
case "id":
|
||||
if err := node.Decode(&m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sawID = true
|
||||
case "options":
|
||||
var options any
|
||||
if err := node.Decode(&options); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Options = options
|
||||
default:
|
||||
return fmt.Errorf("unknown module entry field %q", key)
|
||||
}
|
||||
}
|
||||
if !sawID || m.ID == "" {
|
||||
return fmt.Errorf("module id is required")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("module entry must be a string or mapping")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
@@ -26,6 +29,39 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||
}
|
||||
if cfg.Secrets.Directory != "" {
|
||||
t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory)
|
||||
}
|
||||
if cfg.Notify.Distributor.Enabled {
|
||||
t.Fatalf("Notify.Distributor.Enabled = true, want false")
|
||||
}
|
||||
if cfg.Notify.Distributor.Endpoint != "https://distributor.example.com" {
|
||||
t.Fatalf("Notify.Distributor.Endpoint = %q, want default endpoint", cfg.Notify.Distributor.Endpoint)
|
||||
}
|
||||
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
|
||||
t.Fatalf("Notify.Distributor.TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
|
||||
}
|
||||
if cfg.Notify.Distributor.Timeout != 30*time.Second {
|
||||
t.Fatalf("Notify.Distributor.Timeout = %s, want 30s", cfg.Notify.Distributor.Timeout)
|
||||
}
|
||||
if cfg.Notify.Distributor.FailurePolicy != NotifyFailureError {
|
||||
t.Fatalf("Notify.Distributor.FailurePolicy = %q, want error", cfg.Notify.Distributor.FailurePolicy)
|
||||
}
|
||||
if cfg.Notify.Distributor.PipelineIDTemplate != "" {
|
||||
t.Fatalf("Notify.Distributor.PipelineIDTemplate = %q, want empty", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||
}
|
||||
if cfg.Notify.Distributor.BundleIDTemplate != "weatherreporter.{location_id}.{report_id}" {
|
||||
t.Fatalf("Notify.Distributor.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.BundleIDTemplate)
|
||||
}
|
||||
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
|
||||
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
|
||||
}
|
||||
wantReportPaths := []string{
|
||||
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||
}
|
||||
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") {
|
||||
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths)
|
||||
}
|
||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||
}
|
||||
@@ -49,6 +85,12 @@ func TestLoadExampleConfig(t *testing.T) {
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||
}
|
||||
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" {
|
||||
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||
}
|
||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||
@@ -69,11 +111,146 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||
if cfg.Workspace.Root != "workspace" {
|
||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||
}
|
||||
if cfg.Workspace.NotificationsDir != "notifications" {
|
||||
t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir)
|
||||
}
|
||||
if cfg.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportModuleOverrides(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
`)
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
overrides := cfg.ReportModuleOverrides()
|
||||
items := overrides[report.DailyToday]
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
||||
}
|
||||
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
|
||||
t.Fatalf("daily override = %#v, want configured module order", items)
|
||||
}
|
||||
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != "short_term" {
|
||||
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "UnknownReport",
|
||||
yaml: `
|
||||
reports:
|
||||
moon:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
`,
|
||||
wantErr: "reports.moon",
|
||||
},
|
||||
{
|
||||
name: "UnknownModule",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- missing_module
|
||||
`,
|
||||
wantErr: `unknown module "missing_module"`,
|
||||
},
|
||||
{
|
||||
name: "DuplicateModule",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- metadata
|
||||
`,
|
||||
wantErr: `duplicate module "metadata"`,
|
||||
},
|
||||
{
|
||||
name: "IncompatibleModule",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- storm_window_summary
|
||||
`,
|
||||
wantErr: `not compatible with report "daily_today"`,
|
||||
},
|
||||
{
|
||||
name: "InvalidOptions",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- id: metadata
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
`,
|
||||
wantErr: "options are invalid",
|
||||
},
|
||||
{
|
||||
name: "DuplicateReportAlias",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
daily_today:
|
||||
deterministic_modules:
|
||||
- current_conditions
|
||||
`,
|
||||
wantErr: "duplicates report override",
|
||||
},
|
||||
{
|
||||
name: "UnknownReportField",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
modules:
|
||||
- metadata
|
||||
`,
|
||||
wantErr: `unknown report entry field "modules"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := LoadFile(writeConfig(t, tt.yaml))
|
||||
if err == nil {
|
||||
t.Fatal("LoadFile() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||
if err == nil {
|
||||
@@ -84,6 +261,15 @@ func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
@@ -112,3 +298,475 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
if err := os.WriteFile(path, []byte("notify:\n distributor:\n enabled: false\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Distributor.Enabled {
|
||||
t.Fatalf("Notify.Distributor.Enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Endpoint",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Endpoint = "distributor.example.com"
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
},
|
||||
{
|
||||
name: "TokenEnvEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.TokenEnv = ""
|
||||
},
|
||||
wantErr: "notify.distributor.token_env",
|
||||
},
|
||||
{
|
||||
name: "TokenEnvInvalid",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.TokenEnv = "1TOKEN"
|
||||
},
|
||||
wantErr: "notify.distributor.token_env",
|
||||
},
|
||||
{
|
||||
name: "Timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Timeout = 0
|
||||
},
|
||||
wantErr: "notify.distributor.timeout",
|
||||
},
|
||||
{
|
||||
name: "FailurePolicy",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.FailurePolicy = "warn"
|
||||
},
|
||||
wantErr: "notify.distributor.failure_policy",
|
||||
},
|
||||
{
|
||||
name: "PipelineTemplateEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = ""
|
||||
},
|
||||
wantErr: "notify.distributor.pipeline_id_template",
|
||||
},
|
||||
{
|
||||
name: "PipelineTemplateUnknown",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "{unknown}"
|
||||
},
|
||||
wantErr: "notify.distributor.pipeline_id_template",
|
||||
},
|
||||
{
|
||||
name: "PipelineTemplateRenderedEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = " "
|
||||
},
|
||||
wantErr: "notify.distributor.pipeline_id_template",
|
||||
},
|
||||
{
|
||||
name: "BundleTemplate",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.BundleIDTemplate = "{unknown}"
|
||||
},
|
||||
wantErr: "notify.distributor.bundle_id_template",
|
||||
},
|
||||
{
|
||||
name: "IdempotencyTemplate",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{unknown}"
|
||||
},
|
||||
wantErr: "notify.distributor.idempotency_key_template",
|
||||
},
|
||||
{
|
||||
name: "ReportPathTemplatesEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.ReportPathTemplates = nil
|
||||
},
|
||||
wantErr: "notify.distributor.report_path_templates",
|
||||
},
|
||||
{
|
||||
name: "ReportPathTemplateUnknown",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.ReportPathTemplates = []string{"{unknown}"}
|
||||
},
|
||||
wantErr: "notify.distributor.report_path_templates",
|
||||
},
|
||||
{
|
||||
name: "ReportPathTemplateInvalidPath",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.ReportPathTemplates = []string{"/{batch_output_name}"}
|
||||
},
|
||||
wantErr: "notify.distributor.report_path_templates",
|
||||
},
|
||||
{
|
||||
name: "ReportPathTemplateDuplicatePath",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.ReportPathTemplates = []string{"latest.md", "latest.md"}
|
||||
},
|
||||
wantErr: "notify.distributor.report_path_templates",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
tt.mutate(&cfg)
|
||||
|
||||
err := Validate(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorTemplateRendering(t *testing.T) {
|
||||
values := DistributorTemplateValues{
|
||||
LocationID: "home",
|
||||
ReportID: "daily",
|
||||
RunID: "20260607T120000Z",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
ValidStartDate: "2026-06-07",
|
||||
ValidEndDate: "2026-06-08",
|
||||
ValidStartTime: "1800",
|
||||
ValidEndTime: "0600",
|
||||
ValidStartStamp: "2026-06-07T1800",
|
||||
ValidEndStamp: "2026-06-08T0600",
|
||||
BundleID: "weatherreporter.home.daily",
|
||||
}
|
||||
|
||||
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}", values)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDistributorBundleID() error = %v", err)
|
||||
}
|
||||
if bundleID != "weatherreporter.home.daily" {
|
||||
t.Fatalf("bundleID = %q, want rendered value", bundleID)
|
||||
}
|
||||
|
||||
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
|
||||
}
|
||||
if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" {
|
||||
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
|
||||
}
|
||||
|
||||
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
|
||||
}
|
||||
if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" {
|
||||
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
|
||||
}
|
||||
|
||||
reportPaths, err := RenderDistributorReportPaths([]string{
|
||||
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
|
||||
"{valid_start_date}/{artifact_group}/latest.md",
|
||||
}, values)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDistributorReportPaths() error = %v", err)
|
||||
}
|
||||
wantPaths := []string{
|
||||
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
|
||||
"2026-06-07/daily/latest.md",
|
||||
}
|
||||
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
|
||||
t.Fatalf("reportPaths = %#v, want %#v", reportPaths, wantPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
}{
|
||||
{name: "Unknown", template: "{unknown}"},
|
||||
{name: "Unclosed", template: "{location_id"},
|
||||
{name: "Unopened", template: "location_id}"},
|
||||
{name: "Empty", template: "{}"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
|
||||
if err == nil {
|
||||
t.Fatal("RenderDistributorBundleID() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorReportPathValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{name: "Simple", path: "daily.md", ok: true},
|
||||
{name: "Nested", path: "reports/daily.md", ok: true},
|
||||
{name: "Empty", path: "", ok: false},
|
||||
{name: "Absolute", path: "/reports/daily.md", ok: false},
|
||||
{name: "WindowsAbsolute", path: "C:/reports/daily.md", ok: false},
|
||||
{name: "Backslash", path: `reports\daily.md`, ok: false},
|
||||
{name: "CurrentSegment", path: "reports/./daily.md", ok: false},
|
||||
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
|
||||
{name: "EmptySegment", path: "reports//daily.md", ok: false},
|
||||
{name: "Manifest", path: "reports/manifest.json", ok: false},
|
||||
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateDistributorReportPath("test.path", tt.path)
|
||||
if tt.ok && err != nil {
|
||||
t.Fatalf("ValidateDistributorReportPath() error = %v", err)
|
||||
}
|
||||
if !tt.ok && err == nil {
|
||||
t.Fatal("ValidateDistributorReportPath() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
batchOutputName string
|
||||
}{
|
||||
{name: "Absolute", batchOutputName: "/daily.md"},
|
||||
{name: "Backslash", batchOutputName: `reports\daily.md`},
|
||||
{name: "CurrentSegment", batchOutputName: "./daily.md"},
|
||||
{name: "ParentSegment", batchOutputName: "../daily.md"},
|
||||
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
|
||||
{name: "Manifest", batchOutputName: "manifest.json"},
|
||||
{name: "DistributorMetadata", batchOutputName: ".distributor.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{
|
||||
BatchOutputName: tt.batchOutputName,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||
t.Fatalf("create secrets directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(secretsDir, "DISTRIBUTOR_UPLOAD_TOKEN"), []byte("loaded-token"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
configYAML := "secrets:\n" +
|
||||
" directory: " + secretsDir + "\n" +
|
||||
"notify:\n" +
|
||||
" distributor:\n" +
|
||||
" enabled: true\n" +
|
||||
" pipeline_id_template: weatherreporter.{artifact_group}\n"
|
||||
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DISTRIBUTOR_UPLOAD_TOKEN", "")
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
|
||||
t.Fatalf("TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
|
||||
}
|
||||
if got := os.Getenv(cfg.Notify.Distributor.TokenEnv); got != "loaded-token" {
|
||||
t.Fatalf("environment value = %q, want loaded-token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
|
||||
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{}); err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
|
||||
t.Fatalf("environment value = %q, want original", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileLoadsSecretsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||
t.Fatalf("create secrets directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||
if _, err := LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||
t.Fatalf("environment value = %q, want from-file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
t.Setenv("WEATHERREPORTER_SECRET", "existing")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||
t.Fatalf("environment value = %q, want from-file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "LF", input: "value\n", want: "value"},
|
||||
{name: "CRLF", input: "value\r\n", want: "value"},
|
||||
{name: "TwoLF", input: "value\n\n", want: "value\n"},
|
||||
{name: "LoneCR", input: "value\r", want: "value\r"},
|
||||
{name: "NoNewline", input: "value", want: "value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
|
||||
t.Fatalf("environment value = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, dir string)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "InvalidFilename",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil {
|
||||
t.Fatalf("write invalid secret: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "invalid environment variable name",
|
||||
},
|
||||
{
|
||||
name: "Subdirectory",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil {
|
||||
t.Fatalf("create subdirectory: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "not a directory",
|
||||
},
|
||||
{
|
||||
name: "Symlink",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
target := filepath.Join(dir, "TARGET")
|
||||
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "not a symlink",
|
||||
},
|
||||
{
|
||||
name: "Unreadable",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
path := filepath.Join(dir, "UNREADABLE")
|
||||
if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil {
|
||||
t.Fatalf("write unreadable secret: %v", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o000); err != nil {
|
||||
t.Fatalf("chmod unreadable secret: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(path, 0o600)
|
||||
})
|
||||
},
|
||||
wantErr: "read secret file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tt.setup(t, dir)
|
||||
|
||||
err := loadSecrets(SecretsConfig{Directory: dir})
|
||||
if err == nil {
|
||||
t.Fatal("loadSecrets() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-value") {
|
||||
t.Fatalf("error = %q, want no secret value", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
|
||||
err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
|
||||
if err == nil {
|
||||
t.Fatal("loadSecrets() error = nil, want missing directory error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read secrets directory") {
|
||||
t.Fatalf("error = %q, want read secrets directory context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,24 @@ func Defaults() Config {
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
},
|
||||
Secrets: SecretsConfig{
|
||||
Directory: "",
|
||||
},
|
||||
Notify: NotifyConfig{
|
||||
Distributor: DistributorNotifyConfig{
|
||||
Enabled: false,
|
||||
Endpoint: "https://distributor.example.com",
|
||||
TokenEnv: "DISTRIBUTOR_UPLOAD_TOKEN",
|
||||
Timeout: 30 * time.Second,
|
||||
FailurePolicy: NotifyFailureError,
|
||||
PipelineIDTemplate: "",
|
||||
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
|
||||
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
|
||||
ReportPathTemplates: []string{
|
||||
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
MissingSource: MissingSourceConfig{
|
||||
Default: MissingSourceWarn,
|
||||
Sources: map[string]MissingSourcePolicy{},
|
||||
@@ -32,6 +50,7 @@ func Defaults() Config {
|
||||
ReportsDir: "reports",
|
||||
DataPackagesDir: "data-packages",
|
||||
PreflightDir: "preflight",
|
||||
NotificationsDir: "notifications",
|
||||
},
|
||||
Dayparts: []DaypartConfig{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
@@ -46,5 +65,6 @@ func Defaults() Config {
|
||||
WindGustMilesPerHour: 10,
|
||||
PrecipTimingShiftMinutes: 120,
|
||||
},
|
||||
Reports: map[string]ReportConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ func Load(opts LoadOptions) (Config, error) {
|
||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||
}
|
||||
|
||||
if err := normalizeReportModules(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := loadSecrets(cfg.Secrets); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -57,5 +65,8 @@ func mergeFile(cfg *Config, path string) error {
|
||||
if cfg.MissingSource.Sources == nil {
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||
}
|
||||
if cfg.Reports == nil {
|
||||
cfg.Reports = map[string]ReportConfig{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
201
internal/config/notify_templates.go
Normal file
201
internal/config/notify_templates.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DistributorTemplateValues struct {
|
||||
LocationID string
|
||||
ReportID string
|
||||
RunID string
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
ValidStartDate string
|
||||
ValidEndDate string
|
||||
ValidStartTime string
|
||||
ValidEndTime string
|
||||
ValidStartStamp string
|
||||
ValidEndStamp string
|
||||
BundleID string
|
||||
}
|
||||
|
||||
var distributorTemplateVariables = map[string]struct{}{
|
||||
"location_id": {},
|
||||
"report_id": {},
|
||||
"run_id": {},
|
||||
"artifact_group": {},
|
||||
"batch_output_name": {},
|
||||
"valid_start_date": {},
|
||||
"valid_end_date": {},
|
||||
"valid_start_time": {},
|
||||
"valid_end_time": {},
|
||||
"valid_start_stamp": {},
|
||||
"valid_end_stamp": {},
|
||||
}
|
||||
|
||||
var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
||||
"location_id": {},
|
||||
"report_id": {},
|
||||
"run_id": {},
|
||||
"artifact_group": {},
|
||||
"batch_output_name": {},
|
||||
"valid_start_date": {},
|
||||
"valid_end_date": {},
|
||||
"valid_start_time": {},
|
||||
"valid_end_time": {},
|
||||
"valid_start_stamp": {},
|
||||
"valid_end_stamp": {},
|
||||
"bundle_id": {},
|
||||
}
|
||||
|
||||
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
|
||||
|
||||
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
||||
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
|
||||
}
|
||||
|
||||
func RenderDistributorPipelineID(template string, values DistributorTemplateValues) (string, error) {
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, values, distributorPipelineTemplateVariables)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(rendered) == "" {
|
||||
return "", fmt.Errorf("notify.distributor.pipeline_id_template renders an empty pipeline id")
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
|
||||
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
|
||||
}
|
||||
|
||||
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) {
|
||||
if len(templates) == 0 {
|
||||
return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry")
|
||||
}
|
||||
paths := make([]string, 0, len(templates))
|
||||
seen := make(map[string]struct{}, len(templates))
|
||||
for i, template := range templates {
|
||||
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i)
|
||||
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateDistributorReportPath(name, rendered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[rendered]; ok {
|
||||
return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered)
|
||||
}
|
||||
seen[rendered] = struct{}{}
|
||||
paths = append(paths, rendered)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func validateDistributorTemplate(name, template string, allowed map[string]struct{}) error {
|
||||
_, err := renderDistributorTemplate(name, template, DistributorTemplateValues{}, allowed)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
|
||||
var rendered strings.Builder
|
||||
for i := 0; i < len(template); {
|
||||
switch template[i] {
|
||||
case '{':
|
||||
end := strings.IndexByte(template[i+1:], '}')
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("%s contains an unclosed template variable", name)
|
||||
}
|
||||
variable := template[i+1 : i+1+end]
|
||||
if variable == "" {
|
||||
return "", fmt.Errorf("%s contains an empty template variable", name)
|
||||
}
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||
}
|
||||
rendered.WriteString(distributorTemplateValue(variable, values))
|
||||
i += end + 2
|
||||
case '}':
|
||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
||||
default:
|
||||
rendered.WriteByte(template[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
|
||||
switch variable {
|
||||
case "location_id":
|
||||
return values.LocationID
|
||||
case "report_id":
|
||||
return values.ReportID
|
||||
case "run_id":
|
||||
return values.RunID
|
||||
case "artifact_group":
|
||||
return values.ArtifactGroup
|
||||
case "batch_output_name":
|
||||
return values.BatchOutputName
|
||||
case "valid_start_date":
|
||||
return values.ValidStartDate
|
||||
case "valid_end_date":
|
||||
return values.ValidEndDate
|
||||
case "valid_start_time":
|
||||
return values.ValidStartTime
|
||||
case "valid_end_time":
|
||||
return values.ValidEndTime
|
||||
case "valid_start_stamp":
|
||||
return values.ValidStartStamp
|
||||
case "valid_end_stamp":
|
||||
return values.ValidEndStamp
|
||||
case "bundle_id":
|
||||
return values.BundleID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateDistributorReportPath(name, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("%s renders an empty path", name)
|
||||
}
|
||||
if isDistributorAbsolutePath(path) {
|
||||
return fmt.Errorf("%s must render a relative path", name)
|
||||
}
|
||||
if strings.Contains(path, "\\") {
|
||||
return fmt.Errorf("%s must not render backslashes", name)
|
||||
}
|
||||
|
||||
segments := strings.Split(path, "/")
|
||||
for _, segment := range segments {
|
||||
if segment == "" {
|
||||
return fmt.Errorf("%s must not render empty path segments", name)
|
||||
}
|
||||
if segment == "." || segment == ".." {
|
||||
return fmt.Errorf("%s must not render . or .. path segments", name)
|
||||
}
|
||||
if segment == "manifest.json" || segment == ".distributor.json" {
|
||||
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDistributorAbsolutePath(path string) bool {
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && isASCIIAlpha(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isASCIIAlpha(ch byte) bool {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
|
||||
}
|
||||
125
internal/config/reports.go
Normal file
125
internal/config/reports.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
|
||||
overrides := map[report.ID][]module.ConfigItem{}
|
||||
for key, reportCfg := range cfg.Reports {
|
||||
if !reportCfg.deterministicModulesSet {
|
||||
continue
|
||||
}
|
||||
id, err := reportIDForConfigKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||
for _, item := range reportCfg.DeterministicModules {
|
||||
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
|
||||
}
|
||||
overrides[id] = items
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
func normalizeReportModules(cfg *Config) error {
|
||||
if cfg.Reports == nil {
|
||||
cfg.Reports = map[string]ReportConfig{}
|
||||
}
|
||||
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize module registry: %w", err)
|
||||
}
|
||||
reportRegistry := report.DefaultRegistry()
|
||||
seenReports := map[report.ID]string{}
|
||||
for key, reportCfg := range cfg.Reports {
|
||||
reportID, err := reportIDForConfigKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, ok := seenReports[reportID]; ok {
|
||||
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||
}
|
||||
seenReports[reportID] = key
|
||||
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||
return fmt.Errorf("reports.%s: %w", key, err)
|
||||
}
|
||||
if !reportCfg.deterministicModulesSet {
|
||||
continue
|
||||
}
|
||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||
for i, rawItem := range reportCfg.DeterministicModules {
|
||||
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
||||
}
|
||||
reportCfg.DeterministicModules[i].Options = options
|
||||
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
||||
}
|
||||
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
||||
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||
}
|
||||
cfg.Reports[key] = reportCfg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
definition, err := registry.Lookup(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if definition.DefaultOptions == nil {
|
||||
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||
}
|
||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||
normalized, err := decodeKnownOptions(raw, optionType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module %q options are invalid: %w", id, err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
|
||||
data, err := yaml.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := reflect.New(optionType)
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(target.Interface()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return target.Elem().Interface(), nil
|
||||
}
|
||||
|
||||
func reportIDForConfigKey(key string) (report.ID, error) {
|
||||
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
||||
switch normalized {
|
||||
case "daily", "daily_today":
|
||||
return report.DailyToday, nil
|
||||
case "tomorrow", "daily_tomorrow":
|
||||
return report.DailyTomorrow, nil
|
||||
case "three_day", "three_day_outlook":
|
||||
return report.ThreeDay, nil
|
||||
case "weekend", "weekend_outlook":
|
||||
return report.Weekend, nil
|
||||
case "storm", "storm_report":
|
||||
return report.Storm, nil
|
||||
default:
|
||||
return "", fmt.Errorf("reports.%s is not a known report", key)
|
||||
}
|
||||
}
|
||||
63
internal/config/secrets.go
Normal file
63
internal/config/secrets.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
func loadSecrets(cfg SecretsConfig) error {
|
||||
if cfg.Directory == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cfg.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == "" {
|
||||
return fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory)
|
||||
}
|
||||
if !secretNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("secret file %q has invalid environment variable name", name)
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("secret file %q must be a regular file, not a symlink", name)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return fmt.Errorf("secret file %q must be a regular file, not a directory", name)
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect secret file %q: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("secret file %q must be a regular file", name)
|
||||
}
|
||||
|
||||
path := filepath.Join(cfg.Directory, name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read secret file %q: %w", name, err)
|
||||
}
|
||||
value := string(data)
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
value = strings.TrimSuffix(value, "\r\n")
|
||||
} else {
|
||||
value = strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
if err := os.Setenv(name, value); err != nil {
|
||||
return fmt.Errorf("set environment variable from secret file %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func Validate(cfg Config) error {
|
||||
if err := normalizeReportModules(&cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.WeatherAPI.BaseURL != "" {
|
||||
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
@@ -49,6 +52,10 @@ func Validate(cfg Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateDistributorNotify(cfg.Notify.Distributor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Scriptorium.Binary == "" {
|
||||
return fmt.Errorf("scriptorium.binary is required")
|
||||
}
|
||||
@@ -75,6 +82,76 @@ func Validate(cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(cfg.Endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("notify.distributor.endpoint must be an absolute URL when enabled")
|
||||
}
|
||||
if cfg.TokenEnv == "" {
|
||||
return fmt.Errorf("notify.distributor.token_env is required when enabled")
|
||||
}
|
||||
if !secretNamePattern.MatchString(cfg.TokenEnv) {
|
||||
return fmt.Errorf("notify.distributor.token_env must be a valid environment variable name")
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
return fmt.Errorf("notify.distributor.timeout must be greater than zero when enabled")
|
||||
}
|
||||
if cfg.FailurePolicy != NotifyFailureError {
|
||||
return fmt.Errorf("notify.distributor.failure_policy must be error when enabled")
|
||||
}
|
||||
if cfg.PipelineIDTemplate == "" {
|
||||
return fmt.Errorf("notify.distributor.pipeline_id_template is required when enabled")
|
||||
}
|
||||
if err := validateDistributorTemplate("notify.distributor.pipeline_id_template", cfg.PipelineIDTemplate, distributorPipelineTemplateVariables); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.BundleIDTemplate == "" {
|
||||
return fmt.Errorf("notify.distributor.bundle_id_template is required when enabled")
|
||||
}
|
||||
if err := validateDistributorTemplate("notify.distributor.bundle_id_template", cfg.BundleIDTemplate, distributorTemplateVariables); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.IdempotencyKeyTemplate == "" {
|
||||
return fmt.Errorf("notify.distributor.idempotency_key_template is required when enabled")
|
||||
}
|
||||
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.ReportPathTemplates) == 0 {
|
||||
return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled")
|
||||
}
|
||||
values := DistributorTemplateValues{
|
||||
LocationID: "location",
|
||||
ReportID: "report",
|
||||
RunID: "run",
|
||||
ArtifactGroup: "artifact",
|
||||
BatchOutputName: "report.md",
|
||||
ValidStartDate: "2026-05-29",
|
||||
ValidEndDate: "2026-05-30",
|
||||
ValidStartTime: "0000",
|
||||
ValidEndTime: "0000",
|
||||
ValidStartStamp: "2026-05-29T0000",
|
||||
ValidEndStamp: "2026-05-30T0000",
|
||||
}
|
||||
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values.BundleID = bundleID
|
||||
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePolicy(name string, policy MissingSourcePolicy) error {
|
||||
switch policy {
|
||||
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
|
||||
|
||||
141
internal/facts/facts.go
Normal file
141
internal/facts/facts.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Package facts defines collected and derived report facts.
|
||||
package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type CollectedFacts struct {
|
||||
FetchedAt time.Time
|
||||
Observation *weatherdata.Observation
|
||||
Current *weatherdata.Current
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Discussion *weatherdata.Discussion
|
||||
Daily *weatherdata.ForecastRun
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
|
||||
SourceProvenance []weatherdata.Source
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
}
|
||||
|
||||
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
||||
if bundle == nil {
|
||||
return CollectedFacts{}
|
||||
}
|
||||
return CollectedFacts{
|
||||
FetchedAt: bundle.FetchedAt,
|
||||
Observation: bundle.Observation,
|
||||
Current: bundle.Current,
|
||||
Hourly: bundle.Hourly,
|
||||
Narrative: bundle.Narrative,
|
||||
Alerts: bundle.Alerts,
|
||||
Discussion: bundle.Discussion,
|
||||
Daily: bundle.Daily,
|
||||
WeatherStory: bundle.WeatherStory,
|
||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||
}
|
||||
}
|
||||
|
||||
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: f.FetchedAt,
|
||||
Observation: f.Observation,
|
||||
Current: f.Current,
|
||||
Hourly: f.Hourly,
|
||||
Narrative: f.Narrative,
|
||||
Alerts: f.Alerts,
|
||||
Discussion: f.Discussion,
|
||||
Daily: f.Daily,
|
||||
WeatherStory: f.WeatherStory,
|
||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||
}
|
||||
}
|
||||
|
||||
type BuildDerivedRequest struct {
|
||||
Resolved report.Resolved
|
||||
Timezone string
|
||||
Dayparts []forecast.DaypartDefinition
|
||||
Collected CollectedFacts
|
||||
}
|
||||
|
||||
type DerivedFacts struct {
|
||||
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []forecast.AlertOverlap
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
PrecipTiming forecast.PrecipTiming
|
||||
StormWindowSummary *forecast.DaypartSummary
|
||||
}
|
||||
|
||||
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
|
||||
if len(f.DailySummaries) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &f.DailySummaries[0]
|
||||
}
|
||||
|
||||
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
if !req.Resolved.ValidPeriod.IsValid() {
|
||||
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
|
||||
}
|
||||
location, err := timeutil.LoadLocation(req.Timezone)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
|
||||
}
|
||||
bundle := req.Collected.Bundle()
|
||||
period := req.Resolved.ValidPeriod
|
||||
derived := DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||
}
|
||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
}
|
||||
derived.DailySummaries = []forecast.DailySummary{*summary}
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
}
|
||||
derived.DailySummaries = summaries
|
||||
case report.Storm:
|
||||
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
|
||||
summary.AlertOverlaps = derived.AlertOverlaps
|
||||
derived.StormWindowSummary = &summary
|
||||
default:
|
||||
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
|
||||
derived.DaypartSummaries = collectDaypartSummaries(derived)
|
||||
return derived, nil
|
||||
}
|
||||
|
||||
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||
var out []forecast.DaypartSummary
|
||||
for _, summary := range derived.DailySummaries {
|
||||
out = append(out, summary.Dayparts...)
|
||||
}
|
||||
if derived.StormWindowSummary != nil {
|
||||
out = append(out, *derived.StormWindowSummary)
|
||||
}
|
||||
return out
|
||||
}
|
||||
247
internal/facts/facts_test.go
Normal file
247
internal/facts/facts_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package facts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
fetchedAt := mustParse("2026-05-29T10:00:00Z")
|
||||
bundle := &weatherdata.Bundle{
|
||||
FetchedAt: fetchedAt,
|
||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||
}
|
||||
|
||||
collected := BuildCollected(bundle)
|
||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||
}
|
||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||
}
|
||||
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
|
||||
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
|
||||
}
|
||||
|
||||
bundle.Sources[0].Name = "changed"
|
||||
bundle.Warnings[0].Source = "changed"
|
||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
|
||||
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods))
|
||||
}
|
||||
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods))
|
||||
}
|
||||
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||
t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps)
|
||||
}
|
||||
if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 {
|
||||
t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries)
|
||||
}
|
||||
if len(derived.DaypartSummaries) != 3 {
|
||||
t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries))
|
||||
}
|
||||
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start", derived.PrecipTiming.FirstPrecipitation)
|
||||
}
|
||||
if !derived.PrecipTiming.ThunderMentioned {
|
||||
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
||||
}
|
||||
morning := derived.DailySummaries[0].Dayparts[0]
|
||||
if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 {
|
||||
t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
bundle := testBundle(location)
|
||||
bundle.Narrative = nil
|
||||
bundle.Alerts = nil
|
||||
bundle.Discussion = nil
|
||||
bundle.WeatherStory = nil
|
||||
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.DailySummaries) != 3 {
|
||||
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
|
||||
}
|
||||
if len(derived.ValidPeriodNarrativePeriods) != 0 {
|
||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
|
||||
}
|
||||
if len(derived.AlertOverlaps) != 0 {
|
||||
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
|
||||
}
|
||||
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||
location := testLocation()
|
||||
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
|
||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(%s) error = %v", id, err)
|
||||
}
|
||||
if len(derived.DailySummaries) == 0 {
|
||||
t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveStormForTest(t, location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.ValidPeriodHourlyPeriods) != 2 {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||
}
|
||||
if len(derived.ValidPeriodDailyPeriods) != 1 {
|
||||
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
|
||||
}
|
||||
if derived.StormWindowSummary == nil {
|
||||
t.Fatal("StormWindowSummary = nil, want summary")
|
||||
}
|
||||
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
|
||||
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
|
||||
}
|
||||
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
|
||||
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
|
||||
}
|
||||
}
|
||||
|
||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||
Current: &weatherdata.Current{ConditionText: "Cloudy"},
|
||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10),
|
||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35),
|
||||
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25),
|
||||
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15),
|
||||
hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5),
|
||||
}},
|
||||
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20),
|
||||
}},
|
||||
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20),
|
||||
}},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}},
|
||||
WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||
}
|
||||
}
|
||||
|
||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||
temperature := 70.0
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: mustParse(start).In(location),
|
||||
EndTime: mustParse(end).In(location),
|
||||
TextDescription: text,
|
||||
TemperatureF: &temperature,
|
||||
ProbabilityOfPrecipitationPercent: &precip,
|
||||
WindGustMph: &gust,
|
||||
}
|
||||
}
|
||||
|
||||
func testDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %s: %v", id, err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func testLocation() *time.Location {
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DaypartDefinition struct {
|
||||
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
|
||||
return windows, nil
|
||||
}
|
||||
|
||||
func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period {
|
||||
func PeriodForForecastPeriod(period weatherdata.ForecastPeriod) timeutil.Period {
|
||||
return timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,24 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DailySummary struct {
|
||||
Date string `json:"date"`
|
||||
Period timeutil.Period `json:"period"`
|
||||
Dayparts []DaypartSummary `json:"dayparts"`
|
||||
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
|
||||
Discussion *Discussion `json:"discussion,omitempty"`
|
||||
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
SourceProvenance []Source `json:"sourceProvenance,omitempty"`
|
||||
Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
|
||||
}
|
||||
|
||||
type DaypartSummary struct {
|
||||
Name string `json:"name"`
|
||||
Period timeutil.Period `json:"period"`
|
||||
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"`
|
||||
HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
|
||||
Temperature Range `json:"temperature,omitempty"`
|
||||
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
|
||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
@@ -64,7 +65,39 @@ type AlertOverlap struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
type PrecipTiming struct {
|
||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
||||
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
||||
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
|
||||
}
|
||||
|
||||
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
||||
var timing PrecipTiming
|
||||
for _, forecastPeriod := range periods {
|
||||
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
|
||||
value := TimedValue{
|
||||
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
|
||||
Time: forecastPeriod.StartTime,
|
||||
}
|
||||
if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) {
|
||||
copied := value
|
||||
timing.FirstPrecipitation = &copied
|
||||
}
|
||||
if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) {
|
||||
copied := value
|
||||
timing.LastPrecipitation = &copied
|
||||
}
|
||||
}
|
||||
if mentionsThunder(forecastPeriod.TextDescription) {
|
||||
timing.ThunderMentioned = true
|
||||
}
|
||||
}
|
||||
return timing
|
||||
}
|
||||
|
||||
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
@@ -99,7 +132,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
||||
func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
||||
if !period.IsValid() {
|
||||
return nil, fmt.Errorf("valid forecast period is required")
|
||||
}
|
||||
@@ -121,7 +154,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
@@ -155,11 +188,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
|
||||
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
var selected []ForecastPeriod
|
||||
var selected []weatherdata.ForecastPeriod
|
||||
for _, forecastPeriod := range run.Periods {
|
||||
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
|
||||
selected = append(selected, forecastPeriod)
|
||||
@@ -171,21 +204,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
|
||||
return selected
|
||||
}
|
||||
|
||||
func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod {
|
||||
func SelectNarrativePeriods(bundle *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod {
|
||||
if bundle == nil || bundle.Narrative == nil {
|
||||
return nil
|
||||
}
|
||||
return SelectHourlyPeriods(bundle.Narrative, period)
|
||||
}
|
||||
|
||||
func SelectDiscussion(bundle *Bundle) *Discussion {
|
||||
func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return bundle.Discussion
|
||||
}
|
||||
|
||||
func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary {
|
||||
func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary {
|
||||
summary := DaypartSummary{
|
||||
Name: name,
|
||||
Period: period,
|
||||
@@ -215,7 +248,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
|
||||
return summary
|
||||
}
|
||||
|
||||
func periodTemperatureValues(period ForecastPeriod) []*float64 {
|
||||
func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
|
||||
values := []*float64{}
|
||||
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
|
||||
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
|
||||
@@ -297,7 +330,11 @@ func indicatorsForText(text string) Indicators {
|
||||
}
|
||||
}
|
||||
|
||||
func numericIndicators(period ForecastPeriod) Indicators {
|
||||
func mentionsThunder(text string) bool {
|
||||
return strings.Contains(strings.ToLower(text), "thunder")
|
||||
}
|
||||
|
||||
func numericIndicators(period weatherdata.ForecastPeriod) Indicators {
|
||||
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
|
||||
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
|
||||
indicators := Indicators{}
|
||||
@@ -325,7 +362,7 @@ func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||
}
|
||||
}
|
||||
|
||||
func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap {
|
||||
func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []AlertOverlap {
|
||||
if alertRun == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
||||
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture bundle: %v", err)
|
||||
}
|
||||
var bundle Bundle
|
||||
var bundle weatherdata.Bundle
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("decode fixture bundle: %v", err)
|
||||
}
|
||||
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
||||
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
||||
@@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
||||
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
|
||||
@@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
||||
|
||||
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
||||
location := time.UTC
|
||||
_, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{
|
||||
_, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
})
|
||||
if err == nil {
|
||||
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
||||
|
||||
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
||||
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
||||
@@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
||||
func TestAlertOverlap(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
||||
alertRun := &AlertRun{Alerts: []json.RawMessage{raw}}
|
||||
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||
period := timeutil.Period{
|
||||
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
|
||||
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
|
||||
@@ -209,6 +210,47 @@ func TestAlertOverlap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
||||
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(30), nil, nil),
|
||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
|
||||
hour(location, "2026-05-29T18:00:00-05:00", "2026-05-29T19:00:00-05:00", "Dry", 70, nil, ptr(0), nil, nil),
|
||||
}
|
||||
|
||||
timing := BuildPrecipTiming(periods)
|
||||
|
||||
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
||||
t.Fatalf("FirstPrecipitation = %#v, want 9 AM shower", timing.FirstPrecipitation)
|
||||
}
|
||||
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" {
|
||||
t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation)
|
||||
}
|
||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
||||
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
||||
}
|
||||
if !timing.ThunderMentioned {
|
||||
t.Fatal("ThunderMentioned = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Sunny", 70, nil, ptr(0), nil, nil),
|
||||
}
|
||||
|
||||
timing := BuildPrecipTiming(periods)
|
||||
|
||||
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned {
|
||||
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
|
||||
}
|
||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
||||
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdHelpers(t *testing.T) {
|
||||
if !DifferenceAtLeast(50, 56, 5) {
|
||||
t.Fatal("DifferenceAtLeast = false, want true")
|
||||
@@ -221,31 +263,31 @@ func TestThresholdHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testBundle(location *time.Location) *Bundle {
|
||||
return &Bundle{
|
||||
Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
|
||||
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
|
||||
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
|
||||
}},
|
||||
Narrative: &ForecastRun{Periods: []ForecastPeriod{
|
||||
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
|
||||
}},
|
||||
Alerts: &AlertRun{Alerts: []json.RawMessage{
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
||||
Sources: []Source{{Name: "hourly"}},
|
||||
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||
}
|
||||
}
|
||||
|
||||
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod {
|
||||
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod {
|
||||
startTime := mustParse(start).In(location)
|
||||
endTime := mustParse(end).In(location)
|
||||
temp := temperature
|
||||
return ForecastPeriod{
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TextDescription: text,
|
||||
|
||||
148
internal/module/module.go
Normal file
148
internal/module/module.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package module defines stable module contracts for prompt-facing stanzas.
|
||||
package module
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const SnapshotSchemaVersion = "weatherreporter.modules.v1"
|
||||
|
||||
type ID string
|
||||
|
||||
const (
|
||||
Metadata ID = "metadata"
|
||||
CurrentConditions ID = "current_conditions"
|
||||
DerivedDailySummary ID = "derived_daily_summary"
|
||||
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
||||
HourlyTable ID = "hourly_table"
|
||||
PrecipTiming ID = "precip_timing"
|
||||
AlertDigest ID = "alert_digest"
|
||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||
WeatherStory ID = "weather_story"
|
||||
ForecastDelta ID = "forecast_delta"
|
||||
OutdoorWindows ID = "outdoor_windows"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
WeekendPlanning ID = "weekend_planning"
|
||||
StormWindowSummary ID = "storm_window_summary"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
ID ID `json:"id"`
|
||||
Options any `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
ID ID `json:"id"`
|
||||
StanzaName string `json:"stanzaName"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Outputs []Output `json:"outputs"`
|
||||
}
|
||||
|
||||
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
SchemaVersion: SnapshotSchemaVersion,
|
||||
Outputs: append([]Output(nil), outputs...),
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s Snapshot) Validate() error {
|
||||
if s.SchemaVersion == "" {
|
||||
return fmt.Errorf("schemaVersion is required")
|
||||
}
|
||||
seenModules := map[ID]struct{}{}
|
||||
seenStanzas := map[string]struct{}{}
|
||||
for i, output := range s.Outputs {
|
||||
if output.ID == "" {
|
||||
return fmt.Errorf("outputs[%d].id is required", i)
|
||||
}
|
||||
if output.StanzaName == "" {
|
||||
return fmt.Errorf("outputs[%d].stanzaName is required", i)
|
||||
}
|
||||
if _, ok := seenModules[output.ID]; ok {
|
||||
return fmt.Errorf("duplicate module output %q", output.ID)
|
||||
}
|
||||
seenModules[output.ID] = struct{}{}
|
||||
if _, ok := seenStanzas[output.StanzaName]; ok {
|
||||
return fmt.Errorf("duplicate stanza name %q", output.StanzaName)
|
||||
}
|
||||
seenStanzas[output.StanzaName] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Snapshot) LookupStanza(name string) (Output, bool) {
|
||||
for _, output := range s.Outputs {
|
||||
if output.StanzaName == name {
|
||||
return output, true
|
||||
}
|
||||
}
|
||||
return Output{}, false
|
||||
}
|
||||
|
||||
func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
||||
var zero T
|
||||
output, ok := s.LookupStanza(name)
|
||||
if !ok {
|
||||
return zero, false, nil
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &zero); err != nil {
|
||||
return zero, true, fmt.Errorf("decode stanza %q: %w", name, err)
|
||||
}
|
||||
return zero, true, nil
|
||||
}
|
||||
|
||||
type FactRequirement string
|
||||
|
||||
const (
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary"
|
||||
)
|
||||
|
||||
type MissingDataBehavior string
|
||||
|
||||
const (
|
||||
MissingDataOmit MissingDataBehavior = "omit"
|
||||
MissingDataEmpty MissingDataBehavior = "empty"
|
||||
MissingDataError MissingDataBehavior = "error"
|
||||
MissingDataWarn MissingDataBehavior = "warn"
|
||||
)
|
||||
|
||||
type MetadataOptions struct{}
|
||||
type CurrentConditionsOptions struct{}
|
||||
type DerivedDailySummaryOptions struct{}
|
||||
type DerivedDaypartSummariesOptions struct{}
|
||||
type HourlyTableOptions struct{}
|
||||
type PrecipTimingOptions struct{}
|
||||
type AlertDigestOptions struct{}
|
||||
type AreaForecastDiscussionOptions struct {
|
||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||
}
|
||||
type WeatherStoryOptions struct{}
|
||||
type ForecastDeltaOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
type WeekendPlanningOptions struct{}
|
||||
type StormWindowSummaryOptions struct{}
|
||||
84
internal/module/module_test.go
Normal file
84
internal/module/module_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testStanza struct {
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||
snapshot, err := NewSnapshot([]Output{
|
||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
|
||||
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
if snapshot.SchemaVersion != SnapshotSchemaVersion {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", snapshot.SchemaVersion, SnapshotSchemaVersion)
|
||||
}
|
||||
if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest {
|
||||
t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
want := `{"schemaVersion":"weatherreporter.modules.v1","outputs":[{"id":"metadata","stanzaName":"metadata","value":{"message":"first","count":1}},{"id":"alert_digest","stanzaName":"alert_digest","value":{"message":"second","count":2}}]}`
|
||||
if got != want {
|
||||
t.Fatalf("json = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
||||
_, err := NewSnapshot([]Output{
|
||||
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
|
||||
{ID: Metadata, StanzaName: "other_metadata", Value: struct{}{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate module output "metadata"`) {
|
||||
t.Fatalf("duplicate module error = %v, want duplicate module output", err)
|
||||
}
|
||||
|
||||
_, err = NewSnapshot([]Output{
|
||||
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
|
||||
{ID: CurrentConditions, StanzaName: "metadata", Value: struct{}{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||
t.Fatalf("duplicate stanza error = %v, want duplicate stanza name", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||
snapshot, err := NewSnapshot([]Output{
|
||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
|
||||
value, found, err := StanzaValue[testStanza](snapshot, "metadata")
|
||||
if err != nil {
|
||||
t.Fatalf("StanzaValue() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("StanzaValue() found = false, want true")
|
||||
}
|
||||
if value.Message != "available" || value.Count != 3 {
|
||||
t.Fatalf("StanzaValue() = %#v, want decoded stanza", value)
|
||||
}
|
||||
|
||||
_, found, err = StanzaValue[testStanza](snapshot, "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("StanzaValue(missing) error = %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,93 @@
|
||||
// Package promptinput builds prompt data packages from briefing packages.
|
||||
// Package promptinput builds prompt data packages from module snapshots.
|
||||
package promptinput
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const SchemaVersion = "weatherreporter.data_package.v1"
|
||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
||||
|
||||
type BuildRequest struct {
|
||||
Metadata Metadata
|
||||
Modules module.Snapshot
|
||||
RecentChanges []changes.Change
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
RunID string
|
||||
ReportID report.ID
|
||||
Variant string
|
||||
PromptID string
|
||||
GeneratedAt time.Time
|
||||
Timezone string
|
||||
ValidPeriod timeutil.Period
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
Report Report `json:"report"`
|
||||
Briefing briefing.Package `json:"briefing"`
|
||||
RecentChanges RecentChanges `json:"recentChanges"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
SchemaVersion string `json:"schemaVersion" yaml:"schema_version"`
|
||||
RunID string `json:"runId" yaml:"run_id"`
|
||||
Report Report `json:"report" yaml:"report"`
|
||||
Briefing BriefingStanzas `json:"briefing" yaml:"briefing"`
|
||||
RecentChanges RecentChanges `json:"recentChanges" yaml:"recent_changes"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty" yaml:"source_warnings,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
ID report.ID `json:"id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
CurrentLocalDate string `json:"currentLocalDate"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
ID report.ID `json:"id" yaml:"id"`
|
||||
Variant string `json:"variant,omitempty" yaml:"variant,omitempty"`
|
||||
PromptID string `json:"promptId" yaml:"prompt_id"`
|
||||
GeneratedAt time.Time `json:"generatedAt" yaml:"generated_at"`
|
||||
Timezone string `json:"timezone" yaml:"timezone"`
|
||||
CurrentLocalDate string `json:"currentLocalDate" yaml:"current_local_date"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod" yaml:"valid_period"`
|
||||
}
|
||||
|
||||
type BriefingStanzas struct {
|
||||
Order []string `json:"-" yaml:"-"`
|
||||
Values map[string]any `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
type RecentChanges struct {
|
||||
Items []changes.Change `json:"items"`
|
||||
Items []changes.Change `json:"items" yaml:"items"`
|
||||
}
|
||||
|
||||
func Build(briefingPackage briefing.Package) (Package, error) {
|
||||
return BuildWithRecentChanges(briefingPackage, nil)
|
||||
}
|
||||
|
||||
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||
func Build(req BuildRequest) (Package, error) {
|
||||
localDate, err := currentLocalDate(req.Metadata.GeneratedAt, req.Metadata.Timezone)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
items := make([]changes.Change, len(recentChanges))
|
||||
copy(items, recentChanges)
|
||||
items := make([]changes.Change, len(req.RecentChanges))
|
||||
copy(items, req.RecentChanges)
|
||||
if items == nil {
|
||||
items = []changes.Change{}
|
||||
}
|
||||
pkg := Package{
|
||||
SchemaVersion: SchemaVersion,
|
||||
RunID: briefingPackage.Metadata.RunID,
|
||||
RunID: req.Metadata.RunID,
|
||||
Report: Report{
|
||||
ID: briefingPackage.Metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
PromptID: briefingPackage.Metadata.PromptID,
|
||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||
Timezone: briefingPackage.Metadata.Timezone,
|
||||
ID: req.Metadata.ReportID,
|
||||
Variant: req.Metadata.Variant,
|
||||
PromptID: req.Metadata.PromptID,
|
||||
GeneratedAt: req.Metadata.GeneratedAt,
|
||||
Timezone: req.Metadata.Timezone,
|
||||
CurrentLocalDate: localDate,
|
||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||
ValidPeriod: req.Metadata.ValidPeriod,
|
||||
},
|
||||
Briefing: briefingPackage,
|
||||
Briefing: stanzasFromSnapshot(req.Modules),
|
||||
RecentChanges: RecentChanges{Items: items},
|
||||
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), req.Metadata.SourceWarnings...),
|
||||
}
|
||||
if err := Validate(pkg); err != nil {
|
||||
return Package{}, err
|
||||
@@ -74,6 +95,16 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
|
||||
values := map[string]any{}
|
||||
order := make([]string, 0, len(snapshot.Outputs))
|
||||
for _, output := range snapshot.Outputs {
|
||||
order = append(order, output.StanzaName)
|
||||
values[output.StanzaName] = output.Value
|
||||
}
|
||||
return BriefingStanzas{Order: order, Values: values}
|
||||
}
|
||||
|
||||
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
@@ -86,6 +117,9 @@ func Validate(pkg Package) error {
|
||||
if pkg.SchemaVersion == "" {
|
||||
return fmt.Errorf("schemaVersion is required")
|
||||
}
|
||||
if pkg.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("schemaVersion must be %s", SchemaVersion)
|
||||
}
|
||||
if pkg.RunID == "" {
|
||||
return fmt.Errorf("runId is required")
|
||||
}
|
||||
@@ -107,33 +141,134 @@ func Validate(pkg Package) error {
|
||||
if !pkg.Report.ValidPeriod.IsValid() {
|
||||
return fmt.Errorf("report.validPeriod must be valid")
|
||||
}
|
||||
if pkg.Briefing.Metadata.RunID == "" {
|
||||
return fmt.Errorf("briefing.metadata.runId is required")
|
||||
if len(pkg.Briefing.Order) == 0 {
|
||||
return fmt.Errorf("briefing stanzas are required")
|
||||
}
|
||||
if pkg.Briefing.Metadata.RunID != pkg.RunID {
|
||||
return fmt.Errorf("briefing.metadata.runId must match runId")
|
||||
for _, name := range pkg.Briefing.Order {
|
||||
if name == "" {
|
||||
return fmt.Errorf("briefing stanza name is required")
|
||||
}
|
||||
if pkg.Briefing.Metadata.SchemaVersion == "" {
|
||||
return fmt.Errorf("briefing.metadata.schemaVersion is required")
|
||||
if _, ok := pkg.Briefing.Values[name]; !ok {
|
||||
return fmt.Errorf("briefing stanza %q is missing", name)
|
||||
}
|
||||
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID {
|
||||
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
|
||||
}
|
||||
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
|
||||
return fmt.Errorf("briefing.metadata.reportId must match report.id")
|
||||
}
|
||||
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
|
||||
return fmt.Errorf("briefing report content is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
if err := Validate(pkg); err != nil {
|
||||
data, err := MarshalYAML(pkg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
if err := fileutil.WriteFileAtomic(path, data); err != nil {
|
||||
return fmt.Errorf("save data package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalYAML(pkg Package) ([]byte, error) {
|
||||
if err := Validate(pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := yaml.Marshal(pkg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal data package: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func LoadYAML(data []byte) (Package, error) {
|
||||
var pkg Package
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
if err := decoder.Decode(&pkg); err != nil {
|
||||
return Package{}, fmt.Errorf("decode data package: %w", err)
|
||||
}
|
||||
if err := Validate(pkg); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for _, name := range b.Order {
|
||||
value, ok := b.Values[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
||||
valueNode, err := yamlNode(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
||||
}
|
||||
node.Content = append(node.Content, keyNode, valueNode)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("briefing must be a mapping")
|
||||
}
|
||||
values := map[string]any{}
|
||||
order := make([]string, 0, len(value.Content)/2)
|
||||
for i := 0; i < len(value.Content); i += 2 {
|
||||
name := value.Content[i].Value
|
||||
var stanza any
|
||||
if err := value.Content[i+1].Decode(&stanza); err != nil {
|
||||
return err
|
||||
}
|
||||
order = append(order, name)
|
||||
values[name] = stanza
|
||||
}
|
||||
b.Order = order
|
||||
b.Values = values
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b BriefingStanzas) MarshalJSON() ([]byte, error) {
|
||||
out := map[string]any{}
|
||||
for _, name := range b.Order {
|
||||
if value, ok := b.Values[name]; ok {
|
||||
out[name] = value
|
||||
}
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(data, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
order := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
order = append(order, name)
|
||||
}
|
||||
b.Order = order
|
||||
b.Values = values
|
||||
return nil
|
||||
}
|
||||
|
||||
func yamlNode(value any) (*yaml.Node, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized any
|
||||
if err := json.Unmarshal(data, &normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err = yaml.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal(data, &node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(node.Content) == 0 {
|
||||
return &yaml.Node{Kind: yaml.MappingNode}, nil
|
||||
}
|
||||
return node.Content[0], nil
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
package promptinput
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestBuildDailyDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
@@ -23,7 +20,7 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if pkg.RunID != "20260529T100000Z_daily_today" {
|
||||
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID)
|
||||
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
||||
}
|
||||
if pkg.Report.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||
@@ -31,14 +28,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||
}
|
||||
if pkg.Briefing.Daily == nil {
|
||||
t.Fatal("Briefing.Daily = nil")
|
||||
if pkg.Briefing.Order[0] != "metadata" || pkg.Briefing.Order[1] != "current_conditions" || pkg.Briefing.Order[2] != "derived_daily_summary" {
|
||||
t.Fatalf("Briefing.Order = %#v, want snapshot stanza order", pkg.Briefing.Order)
|
||||
}
|
||||
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
|
||||
}
|
||||
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
|
||||
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
|
||||
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Partly cloudy" {
|
||||
t.Fatalf("current_conditions.condition_text = %q, want Partly cloudy", got)
|
||||
}
|
||||
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
||||
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
||||
@@ -46,11 +40,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||
briefingPackage.Metadata.Timezone = "America/Chicago"
|
||||
req := validBuildRequest(t)
|
||||
req.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||
req.Metadata.Timezone = "America/Chicago"
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
pkg, err := Build(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
@@ -61,10 +55,10 @@ func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.Timezone = "Not/AZone"
|
||||
req := validBuildRequest(t)
|
||||
req.Metadata.Timezone = "Not/AZone"
|
||||
|
||||
_, err := Build(briefingPackage)
|
||||
_, err := Build(req)
|
||||
if err == nil {
|
||||
t.Fatal("Build() error = nil, want invalid timezone error")
|
||||
}
|
||||
@@ -74,7 +68,7 @@ func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRequiresFields(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
@@ -90,7 +84,7 @@ func TestValidateRequiresFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
@@ -105,17 +99,17 @@ func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||
briefingPackage.Metadata.ReportID = report.ThreeDay
|
||||
briefingPackage.Metadata.PromptID = "weather.three_day_outlook"
|
||||
briefingPackage.Daily = nil
|
||||
briefingPackage.ThreeDay = &briefing.ThreeDay{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
|
||||
}
|
||||
func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
req := validBuildRequest(t)
|
||||
req.Metadata.RunID = "20260529T100000Z_three_day"
|
||||
req.Metadata.ReportID = report.ThreeDay
|
||||
req.Metadata.PromptID = "weather.three_day_outlook"
|
||||
req.Modules = snapshotWithOutputs(t,
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||
module.Output{ID: module.ForecastDelta, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||
)
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
pkg, err := Build(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
@@ -123,107 +117,88 @@ func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||
if pkg.Report.ID != report.ThreeDay {
|
||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
||||
}
|
||||
if pkg.Briefing.ThreeDay == nil {
|
||||
t.Fatal("Briefing.ThreeDay = nil")
|
||||
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
|
||||
t.Fatal("Briefing.Values[three_day] missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWeekendDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_weekend"
|
||||
briefingPackage.Metadata.ReportID = report.Weekend
|
||||
briefingPackage.Metadata.PromptID = "weather.weekend_outlook"
|
||||
briefingPackage.Daily = nil
|
||||
briefingPackage.Weekend = &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
|
||||
}
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Report.ID != report.Weekend {
|
||||
t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID)
|
||||
}
|
||||
if pkg.Briefing.Weekend == nil {
|
||||
t.Fatal("Briefing.Weekend = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStormDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
|
||||
briefingPackage.Metadata.ReportID = report.Storm
|
||||
briefingPackage.Metadata.PromptID = "weather.storm_report"
|
||||
briefingPackage.Daily = nil
|
||||
briefingPackage.Storm = &briefing.Storm{
|
||||
TimingWindow: briefingPackage.Metadata.ValidPeriod,
|
||||
Hazards: []string{"Thunderstorms"},
|
||||
}
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Report.ID != report.Storm {
|
||||
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
|
||||
}
|
||||
if pkg.Report.PromptID != "weather.storm_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
|
||||
}
|
||||
if pkg.Briefing.Storm == nil {
|
||||
t.Fatal("Briefing.Storm = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalDeterministic(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
first, err := json.MarshalIndent(pkg, "", " ")
|
||||
first, err := MarshalYAML(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("first marshal: %v", err)
|
||||
}
|
||||
second, err := json.MarshalIndent(pkg, "", " ")
|
||||
second, err := MarshalYAML(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("second marshal: %v", err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
||||
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
||||
}
|
||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
||||
!strings.Contains(string(first), "briefing:\n") ||
|
||||
!strings.Contains(string(first), " current_conditions:\n") ||
|
||||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
|
||||
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
|
||||
}
|
||||
}
|
||||
|
||||
func validBriefingPackage() briefing.Package {
|
||||
func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
data, err := MarshalYAML(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalYAML() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
|
||||
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
|
||||
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
||||
}
|
||||
if loaded.Briefing.Order[1] != "current_conditions" {
|
||||
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func validBuildRequest(t *testing.T) BuildRequest {
|
||||
t.Helper()
|
||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
return BuildRequest{
|
||||
Metadata: Metadata{
|
||||
RunID: "20260529T100000Z_daily_today",
|
||||
ReportID: report.DailyToday,
|
||||
Variant: "today",
|
||||
PromptID: "weather.daily_report",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &briefing.LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
CurrentConditions: &briefing.CurrentConditionsContext{
|
||||
ConditionText: "Partly cloudy",
|
||||
},
|
||||
Daily: &briefing.Daily{
|
||||
ForecastSummaryDate: "2026-05-29",
|
||||
},
|
||||
Modules: snapshotWithOutputs(t,
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithOutputs(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot(outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,7 @@ type Definition struct {
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Modules []module.ConfigItem
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
@@ -63,6 +65,14 @@ func (d Definition) CompatibleWithPrior(id ID) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (d Definition) ModuleIDs() []module.ID {
|
||||
ids := make([]module.ID, 0, len(d.Modules))
|
||||
for _, item := range d.Modules {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
@@ -251,6 +252,140 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
tests := []struct {
|
||||
id ID
|
||||
want []module.ID
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ThreeDay,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.WeekendPlanning,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.HourlyTable,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.StormWindowSummary,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry := DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition, err := registry.Lookup(tt.id)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(definition.ModuleIDs(), tt.want) {
|
||||
t.Fatalf("ModuleIDs() = %#v, want %#v", definition.ModuleIDs(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||
base := DefaultRegistry()
|
||||
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
||||
DailyToday: {
|
||||
{ID: module.Metadata},
|
||||
{ID: module.AlertDigest},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithModuleOverrides() error = %v", err)
|
||||
}
|
||||
|
||||
definition, err := overridden.Lookup(DailyToday)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(overridden) error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(definition.ModuleIDs(), []module.ID{module.Metadata, module.AlertDigest}) {
|
||||
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
|
||||
}
|
||||
|
||||
defaultDefinition, err := base.Lookup(DailyToday)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(default) error = %v", err)
|
||||
}
|
||||
if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) {
|
||||
t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
|
||||
_, err := DefaultRegistry().WithModuleOverrides(map[ID][]module.ConfigItem{
|
||||
ID("unknown"): {{ID: module.Metadata}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("WithModuleOverrides() error = nil, want unknown report")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
|
||||
t.Fatalf("error = %q, want unknown report context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedMetadata(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package report
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
definitions map[ID]Definition
|
||||
@@ -17,6 +21,7 @@ func DefaultRegistry() Registry {
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Modules: dailyTodayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveDailyToday,
|
||||
},
|
||||
@@ -29,6 +34,7 @@ func DefaultRegistry() Registry {
|
||||
BatchOutputName: "tomorrow.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Modules: dailyTomorrowModules(),
|
||||
Evening: true,
|
||||
resolve: resolveDailyTomorrow,
|
||||
},
|
||||
@@ -41,6 +47,7 @@ func DefaultRegistry() Registry {
|
||||
BatchOutputName: "three-day.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{ThreeDay},
|
||||
Modules: threeDayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveThreeDay,
|
||||
},
|
||||
@@ -53,6 +60,7 @@ func DefaultRegistry() Registry {
|
||||
BatchOutputName: "weekend.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Weekend},
|
||||
Modules: weekendModules(),
|
||||
Morning: true,
|
||||
resolve: resolveWeekend,
|
||||
},
|
||||
@@ -65,6 +73,7 @@ func DefaultRegistry() Registry {
|
||||
BatchOutputName: "storm.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Storm},
|
||||
Modules: stormModules(),
|
||||
resolve: resolveStorm,
|
||||
},
|
||||
}
|
||||
@@ -75,6 +84,99 @@ func DefaultRegistry() Registry {
|
||||
return registry
|
||||
}
|
||||
|
||||
func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) {
|
||||
next := Registry{definitions: map[ID]Definition{}}
|
||||
for id, definition := range r.definitions {
|
||||
definition.Modules = append([]module.ConfigItem(nil), definition.Modules...)
|
||||
next.definitions[id] = definition
|
||||
}
|
||||
for id, items := range overrides {
|
||||
definition, ok := next.definitions[id]
|
||||
if !ok {
|
||||
return Registry{}, fmt.Errorf("unknown report %q", id)
|
||||
}
|
||||
definition.Modules = cloneModuleItems(items)
|
||||
next.definitions[id] = definition
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func dailyTodayModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
)
|
||||
}
|
||||
|
||||
func dailyTomorrowModules() []module.ConfigItem {
|
||||
items := dailyTodayModules()
|
||||
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
return items
|
||||
}
|
||||
|
||||
func threeDayModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
)
|
||||
}
|
||||
|
||||
func weekendModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.WeekendPlanning,
|
||||
)
|
||||
}
|
||||
|
||||
func stormModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.HourlyTable,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.StormWindowSummary,
|
||||
)
|
||||
}
|
||||
|
||||
func moduleItems(ids ...module.ID) []module.ConfigItem {
|
||||
items := make([]module.ConfigItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
items = append(items, module.ConfigItem{ID: id})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
|
||||
cloned := make([]module.ConfigItem, len(items))
|
||||
copy(cloned, items)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (r Registry) Lookup(id ID) (Definition, error) {
|
||||
definition, ok := r.definitions[id]
|
||||
if !ok {
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
@@ -23,13 +23,15 @@ type FilesystemStore struct {
|
||||
reportsDir string
|
||||
dataPackagesDir string
|
||||
preflightDir string
|
||||
notificationsDir string
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Briefing string `json:"briefing"`
|
||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||
Metadata string `json:"metadata"`
|
||||
DataPackage string `json:"dataPackage"`
|
||||
Preflight string `json:"preflight"`
|
||||
Notification string `json:"notification,omitempty"`
|
||||
RenderedReport string `json:"renderedReport,omitempty"`
|
||||
}
|
||||
|
||||
@@ -42,7 +44,6 @@ type ReportRecord struct {
|
||||
ValidStart string `json:"validStart"`
|
||||
ValidEnd string `json:"validEnd"`
|
||||
MetadataPath string `json:"metadataPath"`
|
||||
BriefingPath string `json:"briefingPath"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
Warnings int `json:"warnings"`
|
||||
metadata Metadata
|
||||
@@ -57,6 +58,7 @@ func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
|
||||
"reports_dir": cfg.ReportsDir,
|
||||
"data_packages_dir": cfg.DataPackagesDir,
|
||||
"preflight_dir": cfg.PreflightDir,
|
||||
"notifications_dir": cfg.NotificationsDir,
|
||||
} {
|
||||
if err := validateRelativeDir(name, value); err != nil {
|
||||
return nil, err
|
||||
@@ -68,6 +70,7 @@ func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
|
||||
reportsDir: filepath.Clean(cfg.ReportsDir),
|
||||
dataPackagesDir: filepath.Clean(cfg.DataPackagesDir),
|
||||
preflightDir: filepath.Clean(cfg.PreflightDir),
|
||||
notificationsDir: filepath.Clean(cfg.NotificationsDir),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -86,23 +89,27 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
filenameBase := metadata.RunID
|
||||
return ArtifactPaths{
|
||||
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
|
||||
func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Briefing, nil
|
||||
if err := fileutil.WriteJSONAtomic(paths.ModuleSnapshot, snapshot); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.ModuleSnapshot, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
|
||||
@@ -110,10 +117,7 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := promptinput.Validate(pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||
if err := promptinput.Save(paths.DataPackage, pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.DataPackage, nil
|
||||
@@ -130,6 +134,20 @@ func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resol
|
||||
return paths.Preflight, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if artifact.SchemaVersion == "" {
|
||||
artifact.SchemaVersion = DistributorNotificationSchemaVersion
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(paths.Notification, artifact); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Notification, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
@@ -145,8 +163,8 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
||||
if metadata.RunID == "" {
|
||||
return "", fmt.Errorf("metadata run id is required")
|
||||
}
|
||||
if metadata.BriefingPath == "" {
|
||||
return "", fmt.Errorf("metadata briefing path is required")
|
||||
if metadata.ModuleSnapshotPath == "" {
|
||||
return "", fmt.Errorf("metadata module snapshot path is required")
|
||||
}
|
||||
if metadata.DataPackagePath == "" {
|
||||
return "", fmt.Errorf("metadata data package path is required")
|
||||
@@ -217,7 +235,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
||||
})
|
||||
return &PriorSnapshot{
|
||||
Metadata: candidates[0],
|
||||
BriefingPath: candidates[0].BriefingPath,
|
||||
ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -282,13 +300,31 @@ func (s *FilesystemStore) LoadDataPackage(_ context.Context, path string) (promp
|
||||
if path == "" {
|
||||
return promptinput.Package{}, fmt.Errorf("data package path is required")
|
||||
}
|
||||
var pkg promptinput.Package
|
||||
if err := readJSON(path, &pkg); err != nil {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, fmt.Errorf("read %q: %w", path, err)
|
||||
}
|
||||
pkg, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (module.Snapshot, error) {
|
||||
if path == "" {
|
||||
return module.Snapshot{}, fmt.Errorf("module snapshot path is required")
|
||||
}
|
||||
var snapshot module.Snapshot
|
||||
if err := readJSON(path, &snapshot); err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
||||
var metadata Metadata
|
||||
if err := readJSON(path, &metadata); err != nil {
|
||||
@@ -303,7 +339,6 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
||||
ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano),
|
||||
ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano),
|
||||
MetadataPath: path,
|
||||
BriefingPath: metadata.BriefingPath,
|
||||
ReportPath: metadata.RenderedReportPath,
|
||||
Warnings: len(metadata.SourceWarnings),
|
||||
metadata: metadata,
|
||||
@@ -383,14 +418,3 @@ func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
|
||||
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
|
||||
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
|
||||
if path == "" {
|
||||
return briefing.Package{}, fmt.Errorf("briefing path is required")
|
||||
}
|
||||
var pkg briefing.Package
|
||||
if err := readJSON(path, &pkg); err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -26,10 +27,11 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
|
||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.json"),
|
||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
|
||||
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
|
||||
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"),
|
||||
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
|
||||
} {
|
||||
if !strings.Contains(pathsString(paths), want) {
|
||||
@@ -41,24 +43,56 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
briefingPackage := stateBriefingPackage(resolved)
|
||||
dataPackage, err := promptinput.Build(briefingPackage)
|
||||
briefingMetadata := stateBriefingMetadata(resolved)
|
||||
snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||
Metadata: promptinput.Metadata{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
Variant: briefingMetadata.Variant,
|
||||
PromptID: resolved.Definition.PromptID,
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Modules: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDataPackage() error = %v", err)
|
||||
}
|
||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
||||
}
|
||||
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePreflight() error = %v", err)
|
||||
}
|
||||
notificationPath, err := store.SaveDistributorNotification(context.Background(), resolved, DistributorNotificationArtifact{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
AttemptedAt: resolved.GeneratedAt,
|
||||
Endpoint: "https://distributor.example.test",
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
SourcePath: "/tmp/report.md",
|
||||
BundlePaths: []string{"2026-05-29/daily/report.md"},
|
||||
BundleCreated: resolved.GeneratedAt,
|
||||
Status: "succeeded",
|
||||
RunStatus: &DistributorRunStatus{RunID: "distributor-run", Status: "succeeded"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDistributorNotification() error = %v", err)
|
||||
}
|
||||
renderedReportPath, err := store.PrepareRenderedReport(context.Background(), resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareRenderedReport() error = %v", err)
|
||||
@@ -77,12 +111,23 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if preflight.Stdout != `{"ok":true}` {
|
||||
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
|
||||
}
|
||||
var notification DistributorNotificationArtifact
|
||||
notificationData, err := os.ReadFile(notificationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read notification: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(notificationData, ¬ification); err != nil {
|
||||
t.Fatalf("decode notification: %v", err)
|
||||
}
|
||||
if notification.SchemaVersion != DistributorNotificationSchemaVersion || notification.PipelineID != "weatherreporter.daily" || len(notification.BundlePaths) != 1 || notification.RunStatus == nil || notification.RunStatus.Status != "succeeded" {
|
||||
t.Fatalf("notification = %#v, want persisted distributor status", notification)
|
||||
}
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: dataPackagePath,
|
||||
Preflight: preflightPath,
|
||||
@@ -93,17 +138,24 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, renderedReportPath, metadataPath} {
|
||||
for _, path := range []string{moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath)
|
||||
loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBriefing() error = %v", err)
|
||||
t.Fatalf("LoadModuleSnapshot() error = %v", err)
|
||||
}
|
||||
if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID {
|
||||
t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID)
|
||||
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
|
||||
t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
|
||||
}
|
||||
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDataPackage() error = %v", err)
|
||||
}
|
||||
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
|
||||
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
|
||||
}
|
||||
var decoded Metadata
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
@@ -116,7 +168,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if decoded.RunID != resolved.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
|
||||
}
|
||||
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
||||
if decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
||||
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
||||
}
|
||||
if decoded.RenderedReportPath != renderedReportPath {
|
||||
@@ -133,17 +185,16 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
briefingPackage := stateBriefingPackage(resolved)
|
||||
briefingMetadata := stateBriefingMetadata(resolved)
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
|
||||
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
||||
|
||||
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
||||
Briefing: otherBriefingPath,
|
||||
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||
ModuleSnapshot: paths.ModuleSnapshot,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
@@ -168,25 +219,7 @@ func TestFindPriorSnapshot(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||
briefingPackage := stateBriefingPackage(first)
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
@@ -198,8 +231,8 @@ func TestFindPriorSnapshot(t *testing.T) {
|
||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||
}
|
||||
if prior.BriefingPath != briefingPath {
|
||||
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath)
|
||||
if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
|
||||
t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,25 +240,7 @@ func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
briefingPackage := stateBriefingPackage(previousDate)
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(previousDate)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||
if err != nil {
|
||||
@@ -240,25 +255,7 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
|
||||
briefingPackage := stateThreeDayBriefingPackage(first)
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
@@ -276,25 +273,7 @@ func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
|
||||
briefingPackage := stateWeekendBriefingPackage(first)
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
@@ -312,25 +291,7 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
|
||||
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
|
||||
briefingPackage := stateWeekendBriefingPackage(first)
|
||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
||||
Briefing: briefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
@@ -429,10 +390,8 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||
return briefing.Metadata{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
Variant: "today",
|
||||
@@ -447,53 +406,35 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
Timezone: resolved.Timezone,
|
||||
},
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||
}
|
||||
}
|
||||
|
||||
func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
PromptID: resolved.Definition.PromptID,
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
ThreeDay: &briefing.ThreeDay{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
|
||||
},
|
||||
func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths {
|
||||
t.Helper()
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{
|
||||
ModuleSnapshot: paths.ModuleSnapshot,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
SchemaVersion: briefing.SchemaVersion,
|
||||
RunID: resolved.Metadata().RunID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
PromptID: resolved.Definition.PromptID,
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Weekend: &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
|
||||
},
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func pathsString(paths ArtifactPaths) string {
|
||||
return strings.Join([]string{
|
||||
paths.Briefing,
|
||||
paths.Metadata,
|
||||
paths.ModuleSnapshot,
|
||||
paths.DataPackage,
|
||||
paths.Preflight,
|
||||
paths.Notification,
|
||||
paths.RenderedReport,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
||||
@@ -25,31 +25,32 @@ type Metadata struct {
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
BriefingPath string `json:"briefingPath"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
PreflightPath string `json:"preflightPath"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
}
|
||||
|
||||
func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata {
|
||||
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||
metadata := resolved.Metadata()
|
||||
return Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
Variant: briefingMetadata.Variant,
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: metadata.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||
Sources: briefingPackage.Metadata.Sources,
|
||||
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
|
||||
BriefingPath: paths.Briefing,
|
||||
Location: copyLocation(briefingMetadata.Location),
|
||||
SourceLocationID: briefingMetadata.SourceLocationID,
|
||||
SourceLocation: briefingMetadata.SourceLocation,
|
||||
Sources: briefingMetadata.Sources,
|
||||
SourceWarnings: briefingMetadata.SourceWarnings,
|
||||
ModuleSnapshotPath: paths.ModuleSnapshot,
|
||||
DataPackagePath: paths.DataPackage,
|
||||
PreflightPath: paths.Preflight,
|
||||
RenderedReportPath: paths.RenderedReport,
|
||||
|
||||
@@ -3,26 +3,29 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Paths(report.Resolved) (ArtifactPaths, error)
|
||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
||||
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
|
||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
|
||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||
SaveMetadata(context.Context, Metadata) (string, error)
|
||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
|
||||
}
|
||||
|
||||
type PriorSnapshot struct {
|
||||
Metadata Metadata
|
||||
BriefingPath string
|
||||
ModuleSnapshotPath string
|
||||
}
|
||||
|
||||
type PreflightArtifact struct {
|
||||
@@ -33,3 +36,40 @@ type PreflightArtifact struct {
|
||||
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
}
|
||||
|
||||
const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1"
|
||||
|
||||
type DistributorNotificationArtifact struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
AttemptedAt time.Time `json:"attemptedAt"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
PipelineID string `json:"pipelineId,omitempty"`
|
||||
BundleID string `json:"bundleId,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
BundlePaths []string `json:"bundlePaths,omitempty"`
|
||||
BundleCreated time.Time `json:"bundleCreated,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Upload *DistributorUploadResult `json:"upload,omitempty"`
|
||||
RunStatus *DistributorRunStatus `json:"runStatus,omitempty"`
|
||||
StatusError string `json:"statusError,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DistributorUploadResult struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type DistributorRunStatus struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
PipelineID string `json:"pipelineId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AcceptedAt time.Time `json:"acceptedAt,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Report json.RawMessage `json:"report,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package forecast defines normalized weather data consumed by report builders.
|
||||
package forecast
|
||||
// Package weatherdata defines normalized weather data collected from sources.
|
||||
package weatherdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -33,12 +33,12 @@ type Source struct {
|
||||
}
|
||||
|
||||
type SourceWarning struct {
|
||||
Source string `json:"source"`
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
CompletenessImpact string `json:"completenessImpact,omitempty"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Code string `json:"code" yaml:"code"`
|
||||
Severity string `json:"severity" yaml:"severity"`
|
||||
Message string `json:"message" yaml:"message"`
|
||||
Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
|
||||
CompletenessImpact string `json:"completenessImpact,omitempty" yaml:"completeness_impact,omitempty"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
Reference in New Issue
Block a user