Compare commits
21 Commits
bcb4a64c68
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 63749a9572 | |||
| 9ff90d33fc | |||
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 | |||
| 26e6f33cde | |||
| 1bc0739d31 | |||
| 8476dab844 | |||
| 745992886c | |||
| 8089f62806 | |||
| a34aec1dd2 | |||
| 448bd1e510 | |||
| 4e23e1e11f | |||
| 5d3b850e46 | |||
| 4f45dee332 | |||
| 1355605e70 | |||
| 7dc2ac9253 | |||
| 6915bf1ba2 | |||
| ac6ede8f9c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Compiled application binary
|
# Compiled application binary and testing workspace
|
||||||
/weatherreporter
|
/weatherreporter
|
||||||
|
/workspace
|
||||||
|
|
||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
|||||||
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-release-assets
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version="$CI_COMMIT_TAG"
|
||||||
|
dist="dist"
|
||||||
|
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
|
||||||
|
|
||||||
|
rm -rf "$dist"
|
||||||
|
mkdir -p "$dist"
|
||||||
|
|
||||||
|
build_binary() {
|
||||||
|
goos="$1"
|
||||||
|
goarch="$2"
|
||||||
|
suffix="$3"
|
||||||
|
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
|
||||||
|
|
||||||
|
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||||
|
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
|
||||||
|
-o "$output" "$pkg"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_binary linux amd64 ""
|
||||||
|
build_binary linux arm64 ""
|
||||||
|
build_binary darwin amd64 ""
|
||||||
|
build_binary darwin arm64 ""
|
||||||
|
build_binary windows amd64 ".exe"
|
||||||
|
build_binary windows arm64 ".exe"
|
||||||
|
|
||||||
|
- name: publish-release
|
||||||
|
image: woodpeckerci/plugin-release
|
||||||
|
depends_on:
|
||||||
|
- build-release-assets
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: GITEA_RELEASE_TOKEN
|
||||||
|
files:
|
||||||
|
- dist/weatherreporter-*
|
||||||
|
checksum: sha256
|
||||||
|
checksum-file: SHA256SUMS
|
||||||
|
checksum-flatten: true
|
||||||
|
file-exists: skip
|
||||||
|
overwrite: false
|
||||||
|
prerelease: false
|
||||||
@@ -45,12 +45,25 @@ config test suite.
|
|||||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||||
- `precision`: numeric precision query value. Default: `1`.
|
- `precision`: numeric precision query value. Default: `1`.
|
||||||
- `units`: Weather API units query value. Default: `us`.
|
- `units`: Weather API units query value. Default: `us`.
|
||||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `Chicago`.
|
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||||
|
|
||||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||||
|
|
||||||
|
### `location`
|
||||||
|
|
||||||
|
`location` is descriptive prompt context included in briefing metadata and
|
||||||
|
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||||
|
multiple configured forecast locations.
|
||||||
|
|
||||||
|
- `id`: short local identifier. Default: `home`.
|
||||||
|
- `name`: human-readable location name. Default: `Brentwood`.
|
||||||
|
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||||
|
|
||||||
|
The prompt-facing location object also includes `timezone`, derived from the
|
||||||
|
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||||
@@ -88,7 +101,7 @@ Each entry has:
|
|||||||
- `end`
|
- `end`
|
||||||
|
|
||||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
||||||
afternoon, and evening.
|
midday, afternoon, and evening.
|
||||||
|
|
||||||
### `recent_change`
|
### `recent_change`
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`data: null` is treated as a missing source. Missing optional sources follow the
|
For most sources, `data: null` is treated as a missing source. Missing optional
|
||||||
configured missing-source policy. Missing hourly forecast data fails bundle
|
sources follow the configured missing-source policy. Missing hourly forecast
|
||||||
fetching because hourly periods are required for report generation.
|
data fails bundle fetching because hourly periods are required for report
|
||||||
|
generation.
|
||||||
|
|
||||||
|
`/alerts/active` is the exception: a successful response with `data: null`
|
||||||
|
means the endpoint was checked and there are no current active alerts. The
|
||||||
|
adapter records a non-missing alerts source and an empty alert run.
|
||||||
|
|
||||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
@@ -48,7 +53,8 @@ The adapter sends these query parameters:
|
|||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||||
discussion requests
|
discussion requests
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`.
|
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||||
|
`format=json`.
|
||||||
|
|
||||||
## Endpoints Used
|
## Endpoints Used
|
||||||
|
|
||||||
@@ -60,6 +66,7 @@ The adapter fetches these endpoints once per bundle:
|
|||||||
- `/forecast/narrative`
|
- `/forecast/narrative`
|
||||||
- `/alerts/active`
|
- `/alerts/active`
|
||||||
- `/discussion`
|
- `/discussion`
|
||||||
|
- `/weatherstories/latest`
|
||||||
|
|
||||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||||
subsection endpoints. Report-period selection and daypart summarization happen
|
subsection endpoints. Report-period selection and daypart summarization happen
|
||||||
@@ -81,10 +88,11 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `narrative` for `/forecast/narrative`
|
- `narrative` for `/forecast/narrative`
|
||||||
- `alerts` for `/alerts/active`
|
- `alerts` for `/alerts/active`
|
||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
|
||||||
The adapter also creates missing stub source records for `daily` and
|
The adapter also creates a missing stub source record for `daily` because that
|
||||||
`weather_story` because those source slots exist in the internal bundle but are
|
source slot exists in the internal bundle but is not fetched from the Weather
|
||||||
not fetched from the Weather API.
|
API.
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
@@ -92,9 +100,14 @@ Policy behavior:
|
|||||||
- `warn`: omit the source data, add a warning, and continue
|
- `warn`: omit the source data, add a warning, and continue
|
||||||
- `none`: omit the source data and continue without a warning
|
- `none`: omit the source data and continue without a warning
|
||||||
|
|
||||||
|
For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
||||||
|
follows the relevant error path, but explicit `data: null` is not a
|
||||||
|
missing-source condition.
|
||||||
|
|
||||||
## Source Identity
|
## Source Identity
|
||||||
|
|
||||||
For non-null source payloads, the adapter records:
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
|
alerts payload, the adapter records:
|
||||||
|
|
||||||
- source name
|
- source name
|
||||||
- endpoint path
|
- endpoint path
|
||||||
@@ -115,7 +128,9 @@ types in `internal/forecast/bundle.go`, including:
|
|||||||
- current condition values
|
- current condition values
|
||||||
- forecast run metadata and `periods`
|
- forecast run metadata and `periods`
|
||||||
- active alert run data
|
- active alert run data
|
||||||
- discussion metadata, key messages, and short/long-term sections
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
|
- latest weather story title, description, timing, priority, order, alt text,
|
||||||
|
and download URL
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
|||||||
@@ -4,35 +4,46 @@ This document describes the implemented workflow coordinator in `internal/app`.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/app` coordinates top-level use cases: generating one report, running
|
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||||
morning or evening batches, building inspectable briefing artifacts, fetching
|
loading are complete. It resolves report definitions, fetches weather data,
|
||||||
weather bundles, and reading existing artifacts for inspection.
|
builds briefing and prompt-input artifacts, invokes Scriptorium through the
|
||||||
|
adapter boundary, persists managed state, runs batches, and reads existing
|
||||||
|
artifacts for inspection.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- app request structs containing config, report or batch selection, clock time,
|
- `GenerateRequest` for one report command
|
||||||
optional report date, optional Storm Report bounds, output paths, renderer
|
- `BatchRequest` for morning or evening batch commands
|
||||||
fakes, or state-store fakes
|
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||||
|
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
||||||
|
and internal composition
|
||||||
- resolved report definitions from `internal/report`
|
- resolved report definitions from `internal/report`
|
||||||
- forecast bundles from the Weather API adapter
|
- forecast bundles from `internal/adapters/weatherapi`
|
||||||
- prior briefing snapshots from `internal/state`
|
- prior snapshots loaded from `internal/state`
|
||||||
|
- optional renderer and state-store fakes for tests
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- report results with briefing, data package, preflight, report, metadata,
|
- generated report results with briefing, data package, preflight, report,
|
||||||
prior snapshot, Recent Changes, and Scriptorium results
|
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
||||||
- batch summaries with per-report status and artifact paths
|
- batch summaries with per-report status, artifact paths, and error text
|
||||||
|
- saved Weather API bundle JSON for fetch workflows
|
||||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
||||||
snapshots, and source provenance
|
snapshots, and source provenance
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- The package coordinates workflow order.
|
`internal/app` owns workflow order and request composition. It does not parse
|
||||||
- It does not parse CLI flags, load YAML files directly, implement HTTP calls,
|
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
|
||||||
derive forecast facts, define report periods, compare Markdown, or construct
|
facts, define report periods, compare rendered Markdown, or construct
|
||||||
Scriptorium argv.
|
Scriptorium argv.
|
||||||
|
|
||||||
|
Report selection and report identity policy come from `internal/report`.
|
||||||
|
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`.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -42,36 +53,57 @@ Outputs:
|
|||||||
- `dayparts` for daily and outlook summarization
|
- `dayparts` for daily and outlook summarization
|
||||||
- `recent_change.*` for structured Recent Changes thresholds
|
- `recent_change.*` for structured Recent Changes thresholds
|
||||||
|
|
||||||
## External Adapters Used
|
Output copy flags are command request fields. They are not configuration
|
||||||
|
defaults.
|
||||||
|
|
||||||
- `internal/adapters/weatherapi` for forecast bundle fetching
|
## Generation Workflow
|
||||||
- `internal/adapters/scriptorium` for render preflight and report generation
|
|
||||||
- `internal/state` filesystem store for persisted artifacts
|
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
Single-report generation follows this order:
|
||||||
|
|
||||||
Generation saves the briefing snapshot, data package, preflight result when
|
1. Resolve the command report to a `report.Resolved` value.
|
||||||
available, rendered report, and metadata. Metadata links all managed artifact
|
2. Create or use a filesystem store.
|
||||||
paths. Inspection workflows read existing state and do not fetch weather data or
|
3. Locate any prior compatible snapshot through `internal/state`.
|
||||||
invoke Scriptorium.
|
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`.
|
||||||
|
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.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
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.
|
||||||
|
|
||||||
There is no resume workflow. Batch generation continues remaining independent
|
## Batch Workflow
|
||||||
reports after one report fails, then reports aggregate success and failure
|
|
||||||
counts.
|
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Inspection Workflow
|
||||||
|
|
||||||
|
Inspection workflows load existing filesystem state only. They do not fetch
|
||||||
|
weather data or invoke Scriptorium. Run-specific inspect commands share the same
|
||||||
|
store and metadata lookup path, then load the requested artifact or derived
|
||||||
|
inspection view.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Resolve errors stop the requested workflow before fetching weather data.
|
- Resolve errors stop the requested workflow before fetching weather data.
|
||||||
- Weather API or briefing errors stop that report before Scriptorium is called.
|
- Weather API and briefing errors stop that report before Scriptorium runs.
|
||||||
- Render preflight runs before Scriptorium report generation.
|
- Prompt input validation fails before render preflight.
|
||||||
- If render preflight returns a result and an error, preflight JSON and metadata
|
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||||
are persisted before the error is returned.
|
- Metadata and artifact path errors include filesystem context.
|
||||||
- If Scriptorium report generation returns an error after writing output,
|
- Batch failures are recorded per report and surfaced through an aggregate
|
||||||
metadata and the managed report path remain inspectable.
|
batch error.
|
||||||
- Batch failures are recorded per report and surfaced through aggregate batch
|
|
||||||
failure.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -84,6 +116,8 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report behavior is resolved through `internal/report`.
|
- Report behavior is resolved through `internal/report`.
|
||||||
|
- Generated reports use the same app request and result types regardless of
|
||||||
|
report ID.
|
||||||
- Render preflight precedes Scriptorium report generation.
|
- Render preflight precedes Scriptorium report generation.
|
||||||
- Recent Changes are computed from structured briefing snapshots.
|
- Recent Changes are computed from structured briefing snapshots.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ Inputs:
|
|||||||
- resolved report definition, generation time, timezone, and valid period
|
- resolved report definition, generation time, timezone, and valid period
|
||||||
- forecast bundle with source provenance and warnings
|
- forecast bundle with source provenance and warnings
|
||||||
- derived daily or period summaries where required
|
- derived daily or period summaries where required
|
||||||
- configured units and timezone
|
- configured units, timezone, and descriptive location context
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `briefing.Package` with common metadata and one report-specific content
|
- `briefing.Package` with common metadata and one report-specific content
|
||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
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`
|
- optional JSON file written by `briefing.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -34,6 +38,10 @@ Outputs:
|
|||||||
|
|
||||||
The package receives configured units and timezone from the app layer. Daypart
|
The package receives configured units and timezone from the app layer. Daypart
|
||||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
configuration is consumed by `internal/forecast` before briefing 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.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -74,6 +82,6 @@ Inspect:
|
|||||||
|
|
||||||
- Briefings contain structured weather facts and source context.
|
- Briefings contain structured weather facts and source context.
|
||||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||||
provenance, source hashes, and source warnings.
|
provenance, source hashes, source warnings, and configured prompt location.
|
||||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||||
boundary.
|
boundary.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||||
briefing content, Recent Changes, and source warnings
|
briefing content, 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`
|
- optional JSON file written by `promptinput.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -28,8 +33,8 @@ Outputs:
|
|||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
None directly. Config-derived values are already present in briefing metadata
|
None directly. Config-derived values, including timezone and prompt location
|
||||||
before this package runs.
|
context, are already present in briefing metadata before this package runs.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,81 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
This document describes report identity, valid-period resolution, batch
|
This document describes report identity, valid-period resolution, batch
|
||||||
membership, and comparison declarations in `internal/report`.
|
membership, output naming, artifact grouping, and comparison declarations in
|
||||||
|
`internal/report`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` centralizes report definitions so report IDs, prompt IDs,
|
`internal/report` is the canonical source for report definitions. App, state,
|
||||||
default output names, comparison strategies, and valid periods are declared in
|
briefing, and CLI wiring consume resolved definitions instead of owning report
|
||||||
one package.
|
identity policy themselves.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Definition Fields
|
||||||
|
|
||||||
Inputs:
|
Each report definition declares:
|
||||||
|
|
||||||
- report ID or batch name
|
- report ID and display name
|
||||||
- generation time
|
- Scriptorium prompt ID
|
||||||
- timezone
|
- valid-period resolver
|
||||||
- optional Daily date override
|
- comparison strategy
|
||||||
- optional Storm Report start and end times
|
- managed artifact group
|
||||||
|
- batch output copy filename
|
||||||
|
- generated-report eligibility
|
||||||
|
- prior-report compatibility list
|
||||||
|
- morning or evening batch membership
|
||||||
|
|
||||||
Outputs:
|
## Implemented Reports
|
||||||
|
|
||||||
- `report.Resolved` values with definition metadata and half-open valid periods
|
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||||
- `report.Metadata` values used by briefing and persisted metadata builders
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||||
|
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||||
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
## Valid Periods
|
||||||
|
|
||||||
|
- Daily Today covers the selected local civil day, or the current local civil
|
||||||
|
day when no date override is supplied.
|
||||||
|
- Daily Tomorrow covers the next local civil day from generation time.
|
||||||
|
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||||
|
three days later.
|
||||||
|
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||||
|
Sunday morning batch resolution.
|
||||||
|
- Storm Report covers an explicit event window supplied by the caller.
|
||||||
|
|
||||||
|
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||||
|
the configured timezone or RFC3339 timestamps with explicit offsets. End time
|
||||||
|
must be after start time.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package defines report identity and time coverage only.
|
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||||
- It does not fetch weather data, build briefings, compare snapshots, write
|
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||||
state, parse CLI flags, or invoke Scriptorium.
|
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.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Report
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses default output names from report definitions.
|
output path copying uses batch output names from report definitions.
|
||||||
|
|
||||||
## External Adapters Used
|
## State And App Usage
|
||||||
|
|
||||||
None.
|
- State paths use `ArtifactGroup`.
|
||||||
|
- Batch output copies use `BatchOutputName`.
|
||||||
## State Or Manifest Behavior
|
- Generation checks `Generated`.
|
||||||
|
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||||
None directly. Resolved metadata contributes RunID, report ID, prompt ID,
|
- RunIDs include the resolved report ID.
|
||||||
generation time, timezone, and valid period to later briefing and state
|
|
||||||
metadata.
|
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
|
||||||
|
|
||||||
No resume behavior. Morning batch resolution skips Weekend Outlook on Sunday.
|
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Unknown reports and batch names return actionable errors.
|
- Unknown report IDs and batch names return actionable errors.
|
||||||
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||||
- Storm Report resolution requires start and end, with end after start.
|
- Storm Report resolution requires start and end, with end after start.
|
||||||
|
|
||||||
@@ -68,4 +92,6 @@ Inspect:
|
|||||||
- Report selection goes through the registry.
|
- Report selection goes through the registry.
|
||||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||||
- Valid periods are half-open intervals independent of rendered report text.
|
- Valid periods are half-open intervals independent of rendered report text.
|
||||||
- Comparison strategy is declared by report definition.
|
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||||
|
comparison compatibility, and comparison strategy are declared by report
|
||||||
|
definition.
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ This document describes the subprocess adapter in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||||
for Markdown report generation while isolating subprocess details from domain
|
for Markdown report generation. It isolates subprocess execution, argv
|
||||||
packages.
|
construction, timeout handling, output capture, and exit-code interpretation
|
||||||
|
from app and domain packages.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,19 +18,25 @@ Inputs:
|
|||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
|
- context for cancellation
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- argv used for execution
|
- argv used for execution
|
||||||
- captured stdout and stderr with truncation flags
|
- captured stdout and stderr
|
||||||
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This adapter owns Scriptorium argv construction and subprocess execution.
|
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||||
- It does not choose report types, build prompt input, fetch weather data,
|
subprocess execution. It does not choose report types, build prompt input,
|
||||||
decide workflow order, or persist workflow metadata.
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
|
|
||||||
|
The adapter exposes request and result structs for render and run operations.
|
||||||
|
State persistence uses a state-owned preflight artifact shape; app
|
||||||
|
orchestration converts render results before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -39,29 +46,43 @@ Outputs:
|
|||||||
- `scriptorium.timeout`
|
- `scriptorium.timeout`
|
||||||
- `scriptorium.extra_args`
|
- `scriptorium.extra_args`
|
||||||
|
|
||||||
## External Adapters Used
|
## Commands
|
||||||
|
|
||||||
- external `scriptorium` CLI
|
Render preflight argv starts with:
|
||||||
|
|
||||||
See [Scriptorium integration](../integrations/scriptorium.md) for the external
|
```text
|
||||||
CLI contract used by this project.
|
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||||
|
```
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
Report generation argv starts with:
|
||||||
|
|
||||||
`SaveRenderResult` can write render results atomically. The app and state store
|
```text
|
||||||
own managed preflight paths and metadata links.
|
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||||
|
```
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
|
built-in arguments.
|
||||||
|
|
||||||
None. Context cancellation and configured timeout stop subprocess execution.
|
## Execution Behavior
|
||||||
|
|
||||||
|
The adapter runs commands without shell interpolation. The same private
|
||||||
|
execution path is used by render and run after command-specific request
|
||||||
|
validation and argv construction.
|
||||||
|
|
||||||
|
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||||
|
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||||
|
1 MiB each, and marked as truncated when the cap is reached.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Missing prompt ID, data package path, or run output path returns an error.
|
- Missing prompt ID or data package path returns an error before subprocess
|
||||||
- Subprocess start or context errors are wrapped with operation context.
|
execution.
|
||||||
- Nonzero render and run exits return captured output plus an error containing
|
- Missing run output path returns an error before subprocess execution.
|
||||||
exit code and stderr.
|
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||||
- Captured stdout and stderr are size-limited and marked when truncated.
|
operation context by the caller-facing method.
|
||||||
|
- Nonzero render and run exits return the captured result plus an error
|
||||||
|
containing the exit code and stderr.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -75,4 +96,5 @@ Inspect:
|
|||||||
|
|
||||||
- No shell interpolation is used.
|
- No shell interpolation is used.
|
||||||
- The Scriptorium input name is `data_package`.
|
- The Scriptorium input name is `data_package`.
|
||||||
|
- Render and run preserve command-specific result structs.
|
||||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document describes filesystem state in `internal/state`.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/state` owns managed artifact paths, atomic JSON writes, persisted
|
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
@@ -15,7 +15,7 @@ Inputs:
|
|||||||
- resolved report definition and valid period
|
- resolved report definition and valid period
|
||||||
- briefing package
|
- briefing package
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- Scriptorium render result
|
- preflight artifact
|
||||||
- rendered report path preparation request
|
- rendered report path preparation request
|
||||||
- RunID for inspection lookups
|
- RunID for inspection lookups
|
||||||
|
|
||||||
@@ -32,10 +32,13 @@ Outputs:
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns managed workspace layout, path validation, filesystem
|
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||||
writes, and metadata reads.
|
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||||
- It does not fetch weather data, derive forecasts, build prompt input content,
|
data, derive forecasts, build prompt input content, compare briefing contents,
|
||||||
compare briefing contents, invoke Scriptorium, or parse CLI flags.
|
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||||
|
|
||||||
|
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||||
|
converts adapter render results into that shape before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -48,27 +51,48 @@ Outputs:
|
|||||||
Workspace subdirectories must be relative paths that stay under
|
Workspace subdirectories must be relative paths that stay under
|
||||||
`workspace.root`.
|
`workspace.root`.
|
||||||
|
|
||||||
## External Adapters Used
|
## Managed Layout
|
||||||
|
|
||||||
- local filesystem
|
Paths are derived from the resolved report definition's artifact group, the
|
||||||
|
valid-period start date for JSON artifacts, and the RunID.
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
```text
|
||||||
|
<workspace.root>/
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||||
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
|
reports/<artifact_group>/<run_id>.md
|
||||||
|
```
|
||||||
|
|
||||||
Managed paths are grouped by report family and valid-period start date for JSON
|
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||||
artifacts. Reports are written under the report group. Metadata is stored beside
|
package, preflight, report paths, and configured prompt location. Report
|
||||||
briefing snapshots and links briefing, data package, preflight, and report
|
listing walks metadata files under the snapshots directory.
|
||||||
paths. Report listing walks metadata files under the snapshots directory.
|
|
||||||
|
|
||||||
Prior snapshot lookup reads metadata and selects the latest earlier compatible
|
## Prior Lookup
|
||||||
snapshot. Daily Today and Daily Tomorrow are compatible with each other for the
|
|
||||||
same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for
|
|
||||||
the same valid local date. Weekend Outlook compares with prior Weekend snapshots
|
|
||||||
for the same weekend window. Storm Report currently has no prior lookup.
|
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
Prior snapshot lookup reads stored metadata through the shared lookup path and
|
||||||
|
selects the latest earlier snapshot whose report ID is compatible with the
|
||||||
|
current report definition.
|
||||||
|
|
||||||
There is no resume workflow. Missing metadata directories return no inspection
|
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
||||||
records or no prior snapshot rather than creating state.
|
valid local date.
|
||||||
|
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -88,5 +112,6 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Managed paths stay under the configured workspace root.
|
- Managed paths stay under the configured workspace root.
|
||||||
|
- Artifact grouping comes from report definitions.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
- Prior lookup is based on structured metadata, not rendered report text.
|
- Prior lookup is based on structured metadata, not rendered report text.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ This document describes Weather API ingestion into `forecast.Bundle`.
|
|||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||||
derivation and briefing builders.
|
derivation and briefing builders. Briefing builders expose normalized current
|
||||||
|
conditions and weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -19,9 +20,9 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, source records, and source
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
warnings
|
records, and source warnings
|
||||||
- stub source records for daily forecast and weather story source slots
|
- stub source record for the daily forecast source slot
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -53,8 +54,9 @@ contract used by this project.
|
|||||||
|
|
||||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||||
source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for
|
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||||
inspection.
|
represent a checked empty active-alert list, not a missing source.
|
||||||
|
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
@@ -69,6 +71,8 @@ data is required and cannot be skipped.
|
|||||||
endpoint context.
|
endpoint context.
|
||||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||||
- Optional and stub sources follow missing-source policy.
|
- Optional and stub sources follow missing-source policy.
|
||||||
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
|
alert run.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Developers and LLM coding agents should use it with
|
|||||||
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
|
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
|
||||||
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
||||||
validation.
|
validation.
|
||||||
|
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
||||||
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
||||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||||
- `internal/forecast`: normalized bundle types and deterministic forecast
|
- `internal/forecast`: normalized bundle types and deterministic forecast
|
||||||
|
|||||||
@@ -1,794 +0,0 @@
|
|||||||
# Documentation Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the work required to bring `weatherreporter` documentation
|
|
||||||
into compliance with `docs/policy/documentation.md` and the current
|
|
||||||
implementation.
|
|
||||||
|
|
||||||
The migration must keep current-behavior documentation limited to implemented
|
|
||||||
behavior. Planned, future, aspirational, experimental, deprecated, or
|
|
||||||
unimplemented work belongs only under `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Repository Documentation Inventory
|
|
||||||
|
|
||||||
- `README.md` - keep and lightly update. It already provides project purpose,
|
|
||||||
quickstart commands, and documentation links, but it should remain short and
|
|
||||||
should not link to stale implementation-roadmap material as though it were
|
|
||||||
current documentation.
|
|
||||||
- `docs/cli.md` - keep and lightly update. It is the canonical CLI reference
|
|
||||||
and should be checked against `internal/cli/root.go` and
|
|
||||||
`internal/cli/root_test.go`.
|
|
||||||
- `docs/config.md` - keep and lightly update. It is the canonical
|
|
||||||
configuration reference and should be checked against `internal/config/*`,
|
|
||||||
`examples/config.yml`, and config tests.
|
|
||||||
- `docs/operations.md` - keep and lightly update. It documents implemented
|
|
||||||
generation, batch, inspection, artifact, metadata, and recovery behavior.
|
|
||||||
Some recurring failure-mode content should move or link to the new
|
|
||||||
troubleshooting guide.
|
|
||||||
- `docs/troubleshooting.md` - create new. The project has enough implemented
|
|
||||||
CLI, config, state, Weather API, and Scriptorium failure modes to warrant a
|
|
||||||
focused troubleshooting guide.
|
|
||||||
- `docs/internal/briefing.md` - keep and standardize. It documents an
|
|
||||||
implemented component, but it should be aligned with the full internal-doc
|
|
||||||
field list required by the documentation policy.
|
|
||||||
- `docs/internal/changes.md` - keep and standardize. It documents implemented
|
|
||||||
recent-change comparison and should remain scoped to structured snapshots,
|
|
||||||
implemented report compatibility, and current thresholds.
|
|
||||||
- `docs/internal/forecast-derivation.md` - keep and standardize. It documents
|
|
||||||
implemented forecast derivation and should explicitly identify config fields,
|
|
||||||
adapter inputs, state behavior, and invariants.
|
|
||||||
- `docs/internal/prompt-input.md` - keep and standardize. It documents the
|
|
||||||
implemented prompt input package and should stay focused on
|
|
||||||
`internal/promptinput`.
|
|
||||||
- `docs/internal/report-registry.md` - keep and standardize. It documents the
|
|
||||||
implemented registry and valid-period behavior.
|
|
||||||
- `docs/internal/scriptorium-adapter.md` - keep and standardize. It documents
|
|
||||||
the implemented subprocess adapter and should link to
|
|
||||||
`docs/integrations/scriptorium.md` for external contract details.
|
|
||||||
- `docs/internal/state.md` - keep and standardize. It documents implemented
|
|
||||||
filesystem state and should remain canonical for internal state behavior.
|
|
||||||
- `docs/internal/weather-data.md` - keep and standardize. It documents the
|
|
||||||
implemented Weather API adapter and forecast bundle boundary.
|
|
||||||
- `docs/internal/app-orchestration.md` - create new. `internal/app` is the
|
|
||||||
implemented workflow coordinator and needs its own internal component doc.
|
|
||||||
- `docs/integrations/scriptorium.md` - keep and lightly update. It documents an
|
|
||||||
actual external CLI integration and should stay limited to the commands,
|
|
||||||
arguments, inputs, outputs, and exit behavior used by `weatherreporter`.
|
|
||||||
- `docs/integrations/weatherapi.md` - keep and rewrite. It currently describes
|
|
||||||
more Weather API surface than `weatherreporter` uses; narrow it to the
|
|
||||||
implemented fan-out endpoints, envelope conventions, query parameters, and
|
|
||||||
source warning/provenance expectations.
|
|
||||||
- `docs/policy/architecture.md` - keep and lightly update only if accuracy
|
|
||||||
issues are found. It is the canonical architecture policy.
|
|
||||||
- `docs/policy/development.md` - keep and rewrite. It currently reads like a
|
|
||||||
proposed package layout and includes future/MVP language. It should become
|
|
||||||
the canonical contributor workflow document.
|
|
||||||
- `docs/policy/documentation.md` - keep. It is the controlling documentation
|
|
||||||
policy for this migration.
|
|
||||||
- `docs/roadmap/future.md` - keep as the future-only project roadmap. The stale
|
|
||||||
implementation roadmap was removed after deferred work was extracted.
|
|
||||||
- `examples/config.yml` - keep and lightly update. It is a maintained
|
|
||||||
production-oriented example config and should be validated against the
|
|
||||||
implemented config loader.
|
|
||||||
- `examples/minimal-config.yml` - create new only if the implementation pass
|
|
||||||
adds a validation path for it. It should contain the smallest useful config
|
|
||||||
for implemented generation behavior.
|
|
||||||
|
|
||||||
## Policy Compliance Assessment
|
|
||||||
|
|
||||||
Required documents for a modular, CLI/config-driven, stateful application are
|
|
||||||
present: `README.md`, `docs/cli.md`, `docs/config.md`,
|
|
||||||
`docs/operations.md`, `docs/internal/`, and `docs/policy/development.md`.
|
|
||||||
|
|
||||||
Recommended documentation is also present:
|
|
||||||
|
|
||||||
- `docs/troubleshooting.md` covers recurring operator-facing failure modes.
|
|
||||||
- `examples/config.yml` and `examples/minimal-config.yml` are validated by
|
|
||||||
config tests.
|
|
||||||
- `docs/internal/app-orchestration.md` documents the workflow coordinator.
|
|
||||||
|
|
||||||
Documents that were stale or in the wrong canonical home have been corrected:
|
|
||||||
|
|
||||||
- `docs/policy/development.md` is the contributor workflow policy.
|
|
||||||
- `docs/roadmap/future.md` is the current home for deferred project work.
|
|
||||||
- `README.md` links to current user/operator/developer docs.
|
|
||||||
- `docs/integrations/weatherapi.md` is limited to the implemented adapter
|
|
||||||
contract.
|
|
||||||
|
|
||||||
Content that appears planned, historical, or aspirational outside
|
|
||||||
`docs/roadmap/`:
|
|
||||||
|
|
||||||
- Non-roadmap docs should remain limited to implemented behavior. Some
|
|
||||||
policy-level wording about future work is legitimate; feature-specific
|
|
||||||
deferred behavior belongs under `docs/roadmap/`.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `examples/config.yml` and `examples/minimal-config.yml` exist and load
|
|
||||||
through the config test suite.
|
|
||||||
- No generated report examples should be added unless they can be maintained
|
|
||||||
without live Weather API and Scriptorium dependencies.
|
|
||||||
- No workflow examples should be added for unimplemented daemon, cleanup,
|
|
||||||
archive, remote storage, or automatic storm monitoring behavior.
|
|
||||||
|
|
||||||
Links likely needing verification:
|
|
||||||
|
|
||||||
- README should link only to current user/operator/developer docs unless a
|
|
||||||
clearly labeled future-work link is needed.
|
|
||||||
- Internal docs should link to canonical integration docs instead of repeating
|
|
||||||
Scriptorium or Weather API details.
|
|
||||||
- Operations and troubleshooting should link to CLI and config reference rather
|
|
||||||
than duplicating complete flag or field tables.
|
|
||||||
|
|
||||||
## Target Documentation Set
|
|
||||||
|
|
||||||
### `README.md`
|
|
||||||
|
|
||||||
- Audience: users, administrators, operators.
|
|
||||||
- Purpose: concise orientation and shortest useful generation command.
|
|
||||||
- Canonical scope: project purpose, elevator pitch, one minimal usage example,
|
|
||||||
and links to targeted docs.
|
|
||||||
- Recommended outline: description, elevator pitch, shortest useful command,
|
|
||||||
documentation links.
|
|
||||||
- Source-of-truth areas: `cmd/weatherreporter/main.go`,
|
|
||||||
`internal/cli/root.go`, `internal/app/app.go`, `docs/cli.md`.
|
|
||||||
- Acceptance criteria: short enough to remain an orientation page; no
|
|
||||||
unimplemented behavior; no stale roadmap link presented as current docs;
|
|
||||||
commands match `weatherreporter --help`.
|
|
||||||
|
|
||||||
### `docs/cli.md`
|
|
||||||
|
|
||||||
- Audience: users, administrators, operators.
|
|
||||||
- Purpose: canonical CLI reference.
|
|
||||||
- Canonical scope: implemented commands, flags, workflows, diagnostics, and
|
|
||||||
recovery-oriented inspect commands.
|
|
||||||
- Recommended outline: shortest useful command, command overview, flag
|
|
||||||
reference, common workflows, inspection commands, exit behavior.
|
|
||||||
- Source-of-truth areas: `internal/cli/root.go`,
|
|
||||||
`internal/cli/root_test.go`, `internal/app/app.go`,
|
|
||||||
`internal/app/app_test.go`.
|
|
||||||
- Acceptance criteria: every documented command exists; every documented flag is
|
|
||||||
parsed; `generate daily --date` is optional and documented as `YYYY-MM-DD`;
|
|
||||||
batch nonzero behavior is documented; no unimplemented commands appear.
|
|
||||||
|
|
||||||
### `docs/config.md`
|
|
||||||
|
|
||||||
- Audience: administrators, operators, advanced users.
|
|
||||||
- Purpose: canonical configuration reference.
|
|
||||||
- Canonical scope: config discovery, precedence, minimal config, production
|
|
||||||
config, full field reference, and examples.
|
|
||||||
- Recommended outline: file location and precedence, minimal config, production
|
|
||||||
config, field reference, secrets handling, links to examples.
|
|
||||||
- Source-of-truth areas: `internal/config/config.go`,
|
|
||||||
`internal/config/defaults.go`, `internal/config/load.go`,
|
|
||||||
`internal/config/validate.go`, `internal/config/config_test.go`,
|
|
||||||
`examples/config.yml`.
|
|
||||||
- Acceptance criteria: defaults match code; CLI overrides are accurate;
|
|
||||||
`weather_api.base_url` is described as required for generation/fetching;
|
|
||||||
no unused or future config fields appear.
|
|
||||||
|
|
||||||
### `docs/operations.md`
|
|
||||||
|
|
||||||
- Audience: administrators, operators.
|
|
||||||
- Purpose: operational workflows, state layout, inspection, recovery, and caveats.
|
|
||||||
- Canonical scope: implemented generation and batch operation, workspace layout,
|
|
||||||
metadata, artifact inspection, retry/recovery behavior, and explicit
|
|
||||||
non-behavior where operationally important.
|
|
||||||
- Recommended outline: normal workflow, filesystem layout, RunID and metadata,
|
|
||||||
inspection, recent changes, recovery, current operational caveats.
|
|
||||||
- Source-of-truth areas: `internal/app/app.go`, `internal/app/inspect.go`,
|
|
||||||
`internal/state/*`, `internal/report/*`, `internal/cli/root.go`.
|
|
||||||
- Acceptance criteria: workspace paths match `internal/state`; batch behavior
|
|
||||||
matches `RunBatchDetailed`; recovery guidance reflects actual persisted
|
|
||||||
artifacts; troubleshooting details are linked rather than duplicated.
|
|
||||||
|
|
||||||
### `docs/troubleshooting.md`
|
|
||||||
|
|
||||||
- Audience: administrators, operators.
|
|
||||||
- Purpose: symptom-driven diagnosis and safe fixes.
|
|
||||||
- Canonical scope: recurring failure modes for config loading, CLI parsing,
|
|
||||||
Weather API fetching, missing data policy, Scriptorium render/run, workspace
|
|
||||||
state, and inspect commands.
|
|
||||||
- Recommended outline: symptom, likely cause, diagnostic command or inspection
|
|
||||||
step, safe fix, relevant links.
|
|
||||||
- Source-of-truth areas: `internal/cli/root.go`, `internal/config/*`,
|
|
||||||
`internal/adapters/weatherapi/*`, `internal/adapters/scriptorium/*`,
|
|
||||||
`internal/state/*`, tests for those packages.
|
|
||||||
- Acceptance criteria: entries are actionable; no speculative failures; links
|
|
||||||
point to CLI/config/operations/integration docs; no secrets or private
|
|
||||||
infrastructure examples.
|
|
||||||
|
|
||||||
### `docs/policy/architecture.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: canonical architecture policy and invariants.
|
|
||||||
- Canonical scope: project shape, boundaries, dependency policy, configuration,
|
|
||||||
adapters, state, testing, and documentation expectations.
|
|
||||||
- Recommended outline: keep current structure unless implementation inspection
|
|
||||||
reveals an inaccurate invariant.
|
|
||||||
- Source-of-truth areas: current package layout and tests.
|
|
||||||
- Acceptance criteria: policy remains principle-level; it does not become a
|
|
||||||
package manual; current implementation does not obviously violate described
|
|
||||||
invariants.
|
|
||||||
|
|
||||||
### `docs/policy/development.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: canonical contributor workflow.
|
|
||||||
- Canonical scope: repository layout, build/test commands, coding conventions,
|
|
||||||
dependency policy, adding config fields, adding CLI flags, adding components
|
|
||||||
and adapters, updating examples, and documentation expectations.
|
|
||||||
- Recommended outline: repository layout, local validation commands, coding
|
|
||||||
conventions, dependencies, config changes, CLI changes, component/adapters,
|
|
||||||
tests, docs/examples checklist.
|
|
||||||
- Source-of-truth areas: `go.mod`, package layout, `internal/config`,
|
|
||||||
`internal/cli`, adapters, tests, `docs/policy/documentation.md`.
|
|
||||||
- Acceptance criteria: no MVP/proposed/future feature narrative; no duplicate
|
|
||||||
full architecture manual; future work appears only as links to roadmap docs.
|
|
||||||
|
|
||||||
### `docs/internal/app-orchestration.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: describe implemented workflow orchestration in `internal/app`.
|
|
||||||
- Canonical scope: generation flow, batch flow, inspection flow, dependencies,
|
|
||||||
state writes, preflight/run ordering, and failure behavior.
|
|
||||||
- Recommended outline: purpose, inputs and outputs, boundaries, config fields
|
|
||||||
used, adapters used, state behavior, skip/resume behavior, failure behavior,
|
|
||||||
tests, invariants.
|
|
||||||
- Source-of-truth areas: `internal/app/app.go`, `internal/app/inspect.go`,
|
|
||||||
`internal/app/app_test.go`, `internal/state/*`.
|
|
||||||
- Acceptance criteria: documents `scriptorium render` before `scriptorium run`;
|
|
||||||
documents metadata persistence before/after generation failures; does not
|
|
||||||
expose adapter internals beyond orchestration needs.
|
|
||||||
|
|
||||||
### `docs/internal/briefing.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: implemented briefing package and report-specific briefing builders.
|
|
||||||
- Canonical scope: briefing inputs, outputs, boundaries, source warnings,
|
|
||||||
metadata, report variants, tests, and invariants.
|
|
||||||
- Source-of-truth areas: `internal/briefing/*`, `internal/forecast/*`,
|
|
||||||
briefing tests.
|
|
||||||
- Acceptance criteria: includes all internal-doc policy fields; no raw API
|
|
||||||
endpoint documentation duplicated from integrations.
|
|
||||||
|
|
||||||
### `docs/internal/changes.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: structured recent-change comparison.
|
|
||||||
- Canonical scope: comparable snapshot inputs, thresholds, supported report
|
|
||||||
strategies, output items, and empty-result behavior.
|
|
||||||
- Source-of-truth areas: `internal/changes/*`, `internal/report/*`,
|
|
||||||
`internal/state/*`, changes tests.
|
|
||||||
- Acceptance criteria: states that comparisons use structured snapshots, not
|
|
||||||
rendered Markdown; storm current behavior is accurate.
|
|
||||||
|
|
||||||
### `docs/internal/forecast-derivation.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: forecast derivation from normalized bundle data.
|
|
||||||
- Canonical scope: hourly requirement, dayparts, daily/period summaries,
|
|
||||||
alerts, narrative/discussion usage, warnings, and failure behavior.
|
|
||||||
- Source-of-truth areas: `internal/forecast/*`, forecast tests and testdata.
|
|
||||||
- Acceptance criteria: config fields and adapter inputs are explicit; hourly
|
|
||||||
failure behavior matches tests.
|
|
||||||
|
|
||||||
### `docs/internal/prompt-input.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: prompt input data package construction.
|
|
||||||
- Canonical scope: package schema, report metadata, briefing, recent changes,
|
|
||||||
source warnings, validation, and boundaries.
|
|
||||||
- Source-of-truth areas: `internal/promptinput/*`, `internal/app/app.go`,
|
|
||||||
prompt input tests.
|
|
||||||
- Acceptance criteria: uses `data_package` terminology; no `promptvars` or
|
|
||||||
`--vars-file` terminology.
|
|
||||||
|
|
||||||
### `docs/internal/report-registry.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: report registry, prompt IDs, valid periods, batches, and comparison
|
|
||||||
strategy declarations.
|
|
||||||
- Canonical scope: implemented report IDs, prompt IDs, default output naming,
|
|
||||||
period resolution, batch composition, and compatibility behavior.
|
|
||||||
- Source-of-truth areas: `internal/report/*`, report tests,
|
|
||||||
`internal/app/app.go`.
|
|
||||||
- Acceptance criteria: documents `weather.daily_report` for both Daily Today
|
|
||||||
and Daily Tomorrow; no stale `weather.tomorrow_report`.
|
|
||||||
|
|
||||||
### `docs/internal/scriptorium-adapter.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: internal subprocess adapter boundary.
|
|
||||||
- Canonical scope: request structs, argv construction, context/timeouts,
|
|
||||||
stdout/stderr capture, result handling, and errors.
|
|
||||||
- Source-of-truth areas: `internal/adapters/scriptorium/*`,
|
|
||||||
scriptorium adapter tests.
|
|
||||||
- Acceptance criteria: no shell interpolation; domain packages do not receive
|
|
||||||
Scriptorium-specific flags; links to integration contract for CLI details.
|
|
||||||
|
|
||||||
### `docs/internal/state.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: filesystem store, metadata, artifact paths, and lookup behavior.
|
|
||||||
- Canonical scope: workspace layout, RunID-managed files, metadata records,
|
|
||||||
prior snapshot lookup, inspection, atomic write expectations, failure cases.
|
|
||||||
- Source-of-truth areas: `internal/state/*`, state tests,
|
|
||||||
`internal/app/app.go`.
|
|
||||||
- Acceptance criteria: paths match code; no resume/archive/cleanup behavior is
|
|
||||||
documented as implemented.
|
|
||||||
|
|
||||||
### `docs/internal/weather-data.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: Weather API adapter to internal forecast bundle.
|
|
||||||
- Canonical scope: implemented endpoint fan-out, required vs optional sources,
|
|
||||||
missing-source policy, source hashes, provenance, and warnings.
|
|
||||||
- Source-of-truth areas: `internal/adapters/weatherapi/*`,
|
|
||||||
`internal/forecast/bundle.go`, weather adapter tests and testdata.
|
|
||||||
- Acceptance criteria: documents hourly as required; optional/stub sources
|
|
||||||
match code; links to Weather API integration doc for external contract.
|
|
||||||
|
|
||||||
### `docs/integrations/scriptorium.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: external Scriptorium CLI contract used by `weatherreporter`.
|
|
||||||
- Canonical scope: `scriptorium render`, `scriptorium run`,
|
|
||||||
`--input data_package=<path>`, optional config/profile/extra args,
|
|
||||||
stdout/stderr, output path, and exit behavior.
|
|
||||||
- Source-of-truth areas: `internal/adapters/scriptorium/runner.go`,
|
|
||||||
`internal/adapters/scriptorium/runner_test.go`, `internal/config/*`.
|
|
||||||
- Acceptance criteria: only implemented invocation shapes are documented; no
|
|
||||||
unused Scriptorium features appear as project behavior.
|
|
||||||
|
|
||||||
### `docs/integrations/weatherapi.md`
|
|
||||||
|
|
||||||
- Audience: developers and LLM coding agents.
|
|
||||||
- Purpose: external Weather API contract used by `weatherreporter`.
|
|
||||||
- Canonical scope: base URL handling, response envelope, query parameters,
|
|
||||||
implemented endpoints, source identity, timestamps, missing data, and
|
|
||||||
compatibility assumptions.
|
|
||||||
- Source-of-truth areas: `internal/adapters/weatherapi/client.go`,
|
|
||||||
`internal/adapters/weatherapi/client_test.go`, adapter testdata.
|
|
||||||
- Acceptance criteria: documents `/observations`, `/conditions/current`,
|
|
||||||
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and
|
|
||||||
`/discussion`; does not present unused day-slice endpoints as current usage.
|
|
||||||
|
|
||||||
### `docs/roadmap/documentation.md`
|
|
||||||
|
|
||||||
- Audience: maintainers, developers, LLM coding agents.
|
|
||||||
- Purpose: this migration plan.
|
|
||||||
- Canonical scope: planned documentation migration only.
|
|
||||||
- Source-of-truth areas: documentation policy and current repository.
|
|
||||||
- Acceptance criteria: action-oriented; distinguishes required and recommended
|
|
||||||
work; does not rewrite current docs inline; includes validation commands.
|
|
||||||
|
|
||||||
### `docs/roadmap/future.md`
|
|
||||||
|
|
||||||
- Audience: maintainers, developers, LLM coding agents.
|
|
||||||
- Purpose: future-only project work extracted from stale roadmap material.
|
|
||||||
- Canonical scope: deferred features such as automatic storm monitoring, if
|
|
||||||
still desired.
|
|
||||||
- Source-of-truth areas: current code boundaries and deferred work already
|
|
||||||
extracted into this file.
|
|
||||||
- Acceptance criteria: no completed MVP tasks; no claims of current behavior;
|
|
||||||
each item is clearly labeled proposed, accepted, deferred, or rejected.
|
|
||||||
|
|
||||||
### `examples/config.yml`
|
|
||||||
|
|
||||||
- Audience: administrators, operators, developers.
|
|
||||||
- Purpose: production-oriented example configuration.
|
|
||||||
- Canonical scope: implemented config fields only, no secrets.
|
|
||||||
- Source-of-truth areas: `internal/config/*`, `internal/config/config_test.go`.
|
|
||||||
- Acceptance criteria: loads successfully; linked from `docs/config.md`; no
|
|
||||||
private endpoint or credential values.
|
|
||||||
|
|
||||||
### `examples/minimal-config.yml`
|
|
||||||
|
|
||||||
- Audience: administrators, operators, developers.
|
|
||||||
- Purpose: smallest useful config for implemented generation behavior.
|
|
||||||
- Canonical scope: only required or commonly changed fields.
|
|
||||||
- Source-of-truth areas: `internal/config/*`, config tests.
|
|
||||||
- Acceptance criteria: add only with validation coverage; linked from
|
|
||||||
`docs/config.md`; no unimplemented fields.
|
|
||||||
|
|
||||||
## File-by-File Rewrite Guidance
|
|
||||||
|
|
||||||
- `README.md`: keep it short. Cover what the application does, one shortest
|
|
||||||
useful command, and links to canonical docs. Avoid manual-level flag tables,
|
|
||||||
state layout details, implementation stages, or stale roadmap links.
|
|
||||||
- `docs/cli.md`: inspect `internal/cli/root.go` and CLI tests before editing.
|
|
||||||
Cover real commands and flags only. Link to config for config fields and to
|
|
||||||
operations for artifact/state behavior.
|
|
||||||
- `docs/config.md`: inspect `internal/config` and `examples/config.yml`.
|
|
||||||
Document precedence and defaults in one place. Avoid repeating operations or
|
|
||||||
CLI workflow material.
|
|
||||||
- `docs/operations.md`: inspect `internal/app`, `internal/state`, and
|
|
||||||
`internal/report`. Cover normal operation, artifacts, metadata, inspect
|
|
||||||
commands, batch behavior, and recovery. Avoid detailed troubleshooting
|
|
||||||
entries that belong in `docs/troubleshooting.md`.
|
|
||||||
- `docs/troubleshooting.md`: create symptom-driven entries only for implemented
|
|
||||||
failure modes. Likely entries include missing `weather_api.base_url`, invalid
|
|
||||||
timezone, invalid storm bounds, Weather API missing hourly data, optional
|
|
||||||
source warnings, `scriptorium` not found, render/run nonzero exit, missing
|
|
||||||
workspace artifacts, and unknown RunID.
|
|
||||||
- `docs/policy/development.md`: rewrite as contributor workflow. Do not carry
|
|
||||||
forward "proposed layout", "MVP should", "future backend", or automatic storm
|
|
||||||
monitoring guidance except as roadmap links.
|
|
||||||
- `docs/policy/architecture.md`: edit only if a current invariant is wrong.
|
|
||||||
Keep it principle-level and avoid duplicating internal component docs.
|
|
||||||
- `docs/internal/*`: standardize each implemented component doc to the policy
|
|
||||||
field list. Avoid documenting unimplemented resume, cleanup, archive, remote
|
|
||||||
storage, daemon, or automatic storm-monitoring behavior.
|
|
||||||
- `docs/integrations/weatherapi.md`: narrow the endpoint section to actual
|
|
||||||
adapter calls. Document source hash behavior as SHA-256 over canonical,
|
|
||||||
minified raw `data` JSON. Do not carry forward unused endpoint examples as
|
|
||||||
current project behavior.
|
|
||||||
- `docs/integrations/scriptorium.md`: keep `--input data_package=<path>` as the
|
|
||||||
documented input contract. Do not reintroduce `--vars-file` or `promptvars`.
|
|
||||||
- `docs/roadmap/future.md`: keep only deferred work and avoid completed
|
|
||||||
implementation history.
|
|
||||||
- `examples/config.yml`: keep as production-oriented config. Validate it with
|
|
||||||
config-loading tests or an equivalent non-secret check.
|
|
||||||
- `examples/minimal-config.yml`: add only if the implementation agent also adds
|
|
||||||
a validation check and can point `docs/config.md` at it.
|
|
||||||
|
|
||||||
## Examples Plan
|
|
||||||
|
|
||||||
`examples/` currently exists and contains `examples/config.yml`.
|
|
||||||
|
|
||||||
Recommended examples:
|
|
||||||
|
|
||||||
- `examples/config.yml`
|
|
||||||
- Purpose: production-oriented config covering implemented fields.
|
|
||||||
- Expected validity check: load through `internal/config` in tests or an
|
|
||||||
equivalent config validation command.
|
|
||||||
- Documentation links: `docs/config.md`; optional link from README only if the
|
|
||||||
README remains concise.
|
|
||||||
- `examples/minimal-config.yml`
|
|
||||||
- Purpose: smallest useful config for generation against a configured Weather
|
|
||||||
API and Scriptorium installation.
|
|
||||||
- Expected validity check: add test coverage that loads the file and confirms
|
|
||||||
defaults fill omitted fields.
|
|
||||||
- Documentation links: `docs/config.md`.
|
|
||||||
|
|
||||||
Do not add generated report examples in this migration. They depend on live
|
|
||||||
Weather API and Scriptorium behavior and would become stale unless fixture-based
|
|
||||||
generation is implemented.
|
|
||||||
|
|
||||||
Do not add examples for automatic storm monitoring, daemon operation, cleanup,
|
|
||||||
archive, remote storage, or resume behavior because those are not implemented.
|
|
||||||
|
|
||||||
## Internal Documentation Plan
|
|
||||||
|
|
||||||
- App orchestration
|
|
||||||
- Path: `docs/internal/app-orchestration.md`
|
|
||||||
- Purpose: explain implemented generation, batch, inspection, preflight, run,
|
|
||||||
and persistence ordering.
|
|
||||||
- Inputs and outputs: app requests, config, report definitions, forecast
|
|
||||||
bundle, briefing, data package, metadata, preflight result, rendered report,
|
|
||||||
batch summary.
|
|
||||||
- Boundaries: coordinates packages but does not own forecast derivation,
|
|
||||||
config parsing, adapter internals, or report definitions.
|
|
||||||
- Config fields used: Weather API, Scriptorium, workspace, report output,
|
|
||||||
dayparts, recent-change thresholds.
|
|
||||||
- Adapters used: Weather API and Scriptorium.
|
|
||||||
- Failure behavior: render failures persist preflight and metadata when
|
|
||||||
available; run failures leave inspectable artifacts; batches continue
|
|
||||||
independent reports and return aggregate failure through CLI.
|
|
||||||
- Tests to inspect: `internal/app/app_test.go`, CLI batch tests, state tests.
|
|
||||||
- Architectural invariants: render preflight precedes run; domain logic stays
|
|
||||||
outside adapters; metadata links artifacts.
|
|
||||||
|
|
||||||
- Weather data
|
|
||||||
- Path: `docs/internal/weather-data.md`
|
|
||||||
- Purpose: document Weather API fan-out into `forecast.Bundle`.
|
|
||||||
- Inputs and outputs: config, HTTP responses, source records, warnings,
|
|
||||||
bundle.
|
|
||||||
- Boundaries: adapter handles transport and normalization boundary, not
|
|
||||||
forecast selection policy.
|
|
||||||
- Config fields used: base URL, timeout, precision, units, timezone, format,
|
|
||||||
missing-source policy.
|
|
||||||
- Adapters used: Weather API HTTP client.
|
|
||||||
- Failure behavior: hourly is required; optional and stub sources follow
|
|
||||||
missing-source policy.
|
|
||||||
- Tests to inspect: `internal/adapters/weatherapi/client_test.go` and
|
|
||||||
adapter testdata.
|
|
||||||
- Architectural invariants: source provenance and warning records are
|
|
||||||
first-class.
|
|
||||||
|
|
||||||
- Forecast derivation
|
|
||||||
- Path: `docs/internal/forecast-derivation.md`
|
|
||||||
- Purpose: document deriving daily and period summaries from bundle data.
|
|
||||||
- Inputs and outputs: bundle, dayparts, valid period, summary structures,
|
|
||||||
warnings.
|
|
||||||
- Boundaries: no HTTP, state, or Scriptorium calls.
|
|
||||||
- Config fields used: dayparts and relevant threshold settings.
|
|
||||||
- Adapters used: none directly.
|
|
||||||
- Failure behavior: missing required hourly data fails report preparation.
|
|
||||||
- Tests to inspect: `internal/forecast/derive_test.go`.
|
|
||||||
- Architectural invariants: Go owns period selection and meteorological
|
|
||||||
summarization.
|
|
||||||
|
|
||||||
- Report registry
|
|
||||||
- Path: `docs/internal/report-registry.md`
|
|
||||||
- Purpose: document report IDs, prompt IDs, valid periods, output naming,
|
|
||||||
batches, and comparison strategies.
|
|
||||||
- Inputs and outputs: report kind, clock, timezone, manual bounds, resolved
|
|
||||||
report definition.
|
|
||||||
- Boundaries: does not build briefings or execute workflows.
|
|
||||||
- Config fields used: timezone and report output settings.
|
|
||||||
- Adapters used: none.
|
|
||||||
- Failure behavior: invalid report kinds, invalid dates, and invalid manual
|
|
||||||
storm bounds fail before generation.
|
|
||||||
- Tests to inspect: `internal/report/period_test.go`.
|
|
||||||
- Architectural invariants: report-specific behavior is registry-driven.
|
|
||||||
|
|
||||||
- Briefing
|
|
||||||
- Path: `docs/internal/briefing.md`
|
|
||||||
- Purpose: document report-specific briefing package builders.
|
|
||||||
- Inputs and outputs: resolved report, forecast summaries, source metadata,
|
|
||||||
source warnings, briefing package.
|
|
||||||
- Boundaries: no prompt rendering and no external calls.
|
|
||||||
- Config fields used: dayparts and report period inputs.
|
|
||||||
- Adapters used: none directly.
|
|
||||||
- Failure behavior: invalid or insufficient forecast summaries fail before
|
|
||||||
prompt input construction.
|
|
||||||
- Tests to inspect: briefing package tests.
|
|
||||||
- Architectural invariants: briefings are curated inputs for prompts.
|
|
||||||
|
|
||||||
- Prompt input
|
|
||||||
- Path: `docs/internal/prompt-input.md`
|
|
||||||
- Purpose: document data package construction for Scriptorium.
|
|
||||||
- Inputs and outputs: metadata, briefing, recent changes, warnings,
|
|
||||||
`data_package` JSON.
|
|
||||||
- Boundaries: no subprocess execution.
|
|
||||||
- Config fields used: none directly except values already recorded in
|
|
||||||
metadata/briefing.
|
|
||||||
- Adapters used: none directly.
|
|
||||||
- Failure behavior: validation errors fail before render/run.
|
|
||||||
- Tests to inspect: `internal/promptinput/package_test.go`.
|
|
||||||
- Architectural invariants: Scriptorium receives structured prompt input, not
|
|
||||||
raw unbounded source payloads.
|
|
||||||
|
|
||||||
- Recent changes
|
|
||||||
- Path: `docs/internal/changes.md`
|
|
||||||
- Purpose: document structured comparison of current and prior briefings.
|
|
||||||
- Inputs and outputs: current briefing, prior comparable briefing, thresholds,
|
|
||||||
change items.
|
|
||||||
- Boundaries: no Markdown comparison and no external calls.
|
|
||||||
- Config fields used: `recent_change` thresholds.
|
|
||||||
- Adapters used: none.
|
|
||||||
- Failure behavior: no prior comparable snapshot produces an empty change set.
|
|
||||||
- Tests to inspect: `internal/changes/*_test.go`.
|
|
||||||
- Architectural invariants: comparisons are structured and report-compatible.
|
|
||||||
|
|
||||||
- Filesystem state
|
|
||||||
- Path: `docs/internal/state.md`
|
|
||||||
- Purpose: document workspace paths, metadata, artifact persistence, lookup,
|
|
||||||
and inspection support.
|
|
||||||
- Inputs and outputs: workspace config, RunID, artifact JSON/Markdown,
|
|
||||||
metadata records, lookup results.
|
|
||||||
- Boundaries: no forecast logic and no subprocess execution.
|
|
||||||
- Config fields used: workspace directories and report output directory.
|
|
||||||
- Adapters used: filesystem.
|
|
||||||
- Failure behavior: path, write, read, and lookup errors are surfaced with
|
|
||||||
context.
|
|
||||||
- Tests to inspect: `internal/state/filesystem_test.go`.
|
|
||||||
- Architectural invariants: persisted artifacts support inspection and retry
|
|
||||||
diagnosis.
|
|
||||||
|
|
||||||
- Scriptorium adapter
|
|
||||||
- Path: `docs/internal/scriptorium-adapter.md`
|
|
||||||
- Purpose: document subprocess isolation and result handling.
|
|
||||||
- Inputs and outputs: render/run requests, argv, stdout, stderr, exit code,
|
|
||||||
report path.
|
|
||||||
- Boundaries: owns subprocess invocation only; does not know report semantics.
|
|
||||||
- Config fields used: binary, config path, profile, timeout, extra args.
|
|
||||||
- Adapters used: external `scriptorium` CLI.
|
|
||||||
- Failure behavior: context timeouts and nonzero exits return captured output.
|
|
||||||
- Tests to inspect: `internal/adapters/scriptorium/runner_test.go`.
|
|
||||||
- Architectural invariants: no shell interpolation; Scriptorium details do not
|
|
||||||
leak into domain packages.
|
|
||||||
|
|
||||||
## Integration Documentation Plan
|
|
||||||
|
|
||||||
- Weather API
|
|
||||||
- Path: `docs/integrations/weatherapi.md`
|
|
||||||
- External system or contract: internal weatherfeeder-backed Weather API.
|
|
||||||
- Current usage: the adapter fetches observations, current conditions, hourly
|
|
||||||
forecast, narrative forecast, active alerts, and discussion; daily forecast
|
|
||||||
and weather story are internal stub source slots.
|
|
||||||
- Version or compatibility notes: no explicit external version is visible in
|
|
||||||
the repository; document compatibility in terms of endpoints, query
|
|
||||||
parameters, and response envelope shape used by tests.
|
|
||||||
- What should be documented: base URL joining, timeout behavior, query
|
|
||||||
parameters (`format`, `units`, `tz`, `precision` where used), `data`
|
|
||||||
envelope handling, `data:null`, source hashes, warnings, and required vs
|
|
||||||
optional sources.
|
|
||||||
- What should not be documented: unused endpoint families, unimplemented
|
|
||||||
location selection, upstream implementation internals, or private
|
|
||||||
deployment details.
|
|
||||||
|
|
||||||
- Scriptorium
|
|
||||||
- Path: `docs/integrations/scriptorium.md`
|
|
||||||
- External system or contract: `scriptorium` CLI.
|
|
||||||
- Current usage: `scriptorium render --prompt <prompt_id> --input
|
|
||||||
data_package=<path> --format json` and `scriptorium run --prompt
|
|
||||||
<prompt_id> --input data_package=<path> --out <artifact_path>`, with
|
|
||||||
configured binary/config/profile/extra args where applicable.
|
|
||||||
- Version or compatibility notes: no explicit Scriptorium version is visible
|
|
||||||
in the repository; document the argv contract and behavior expected by
|
|
||||||
adapter tests.
|
|
||||||
- What should be documented: input contract, output path behavior,
|
|
||||||
stdout/stderr capture, nonzero exit handling, timeout behavior, and security
|
|
||||||
notes about no shell interpolation.
|
|
||||||
- What should not be documented: unused Scriptorium modes, prompt authoring
|
|
||||||
guidance beyond the input contract, or Scriptorium internals.
|
|
||||||
|
|
||||||
## Recommended Implementation Sequence
|
|
||||||
|
|
||||||
### Stage 1: Baseline And User Docs
|
|
||||||
|
|
||||||
- Goal: align README, CLI, and config docs with implemented behavior.
|
|
||||||
- Files to create/update/delete/move: update `README.md`, `docs/cli.md`,
|
|
||||||
`docs/config.md`.
|
|
||||||
- Repository areas to inspect: `cmd/weatherreporter/main.go`,
|
|
||||||
`internal/cli/root.go`, `internal/cli/root_test.go`, `internal/config/*`,
|
|
||||||
`internal/config/config_test.go`, `examples/config.yml`.
|
|
||||||
- Acceptance criteria: no unimplemented commands or config fields; quickstart
|
|
||||||
commands match `weatherreporter --help`; config defaults and precedence match
|
|
||||||
code; README remains concise.
|
|
||||||
- Suggested validation commands: `go run ./cmd/weatherreporter --help`,
|
|
||||||
`go test ./internal/cli ./internal/config`, stale-term greps for removed CLI
|
|
||||||
or prompt terminology.
|
|
||||||
- Prompt size: small enough for one implementation prompt.
|
|
||||||
|
|
||||||
### Stage 2: Operations And Troubleshooting
|
|
||||||
|
|
||||||
- Goal: keep operations focused on normal workflows and recovery; create a
|
|
||||||
symptom-driven troubleshooting guide.
|
|
||||||
- Files to create/update/delete/move: update `docs/operations.md`; create
|
|
||||||
`docs/troubleshooting.md`.
|
|
||||||
- Repository areas to inspect: `internal/app/app.go`, `internal/app/inspect.go`,
|
|
||||||
`internal/state/*`, `internal/report/*`, `internal/adapters/weatherapi/*`,
|
|
||||||
`internal/adapters/scriptorium/*`.
|
|
||||||
- Acceptance criteria: recovery and troubleshooting are clearly separated;
|
|
||||||
failure entries are actionable; state layout matches code; no resume,
|
|
||||||
cleanup, archive, remote storage, or daemon behavior is documented as
|
|
||||||
implemented.
|
|
||||||
- Suggested validation commands: `go test ./internal/app ./internal/state`,
|
|
||||||
`go run ./cmd/weatherreporter --help`, link review.
|
|
||||||
- Prompt size: small enough for one implementation prompt.
|
|
||||||
|
|
||||||
### Stage 3: Internal Component Docs
|
|
||||||
|
|
||||||
- Goal: bring internal docs into the policy-required shape and add missing app
|
|
||||||
orchestration documentation.
|
|
||||||
- Files to create/update/delete/move: create
|
|
||||||
`docs/internal/app-orchestration.md`; update all existing `docs/internal/*.md`.
|
|
||||||
- Repository areas to inspect: `internal/app`, `internal/forecast`,
|
|
||||||
`internal/briefing`, `internal/promptinput`, `internal/changes`,
|
|
||||||
`internal/report`, `internal/state`, adapters, and related tests.
|
|
||||||
- Acceptance criteria: each component doc includes purpose, inputs/outputs,
|
|
||||||
boundaries, config fields, adapters, state behavior, skip/resume behavior,
|
|
||||||
failure behavior, tests, and invariants; no unimplemented components are
|
|
||||||
documented.
|
|
||||||
- Suggested validation commands: `go test ./internal/...`, grep for stale terms
|
|
||||||
in `docs/internal`.
|
|
||||||
- Prompt size: likely too large for one prompt unless handled mechanically;
|
|
||||||
split into app/state/adapters and domain/report/briefing docs if needed.
|
|
||||||
|
|
||||||
### Stage 4: Integration Contracts
|
|
||||||
|
|
||||||
- Goal: make integration docs concise and limited to actual external contracts
|
|
||||||
used by `weatherreporter`.
|
|
||||||
- Files to create/update/delete/move: rewrite
|
|
||||||
`docs/integrations/weatherapi.md`; lightly update
|
|
||||||
`docs/integrations/scriptorium.md`.
|
|
||||||
- Repository areas to inspect: `internal/adapters/weatherapi/*`,
|
|
||||||
weather adapter testdata, `internal/adapters/scriptorium/*`,
|
|
||||||
`internal/config/*`.
|
|
||||||
- Acceptance criteria: no unused Weather API endpoint surface is presented as
|
|
||||||
current behavior; Scriptorium docs use `--input data_package=<path>`; no
|
|
||||||
`--vars-file` or `promptvars` terminology.
|
|
||||||
- Suggested validation commands: `go test ./internal/adapters/weatherapi
|
|
||||||
./internal/adapters/scriptorium`, stale-term greps.
|
|
||||||
- Prompt size: small enough for one implementation prompt.
|
|
||||||
|
|
||||||
### Stage 5: Policy Docs
|
|
||||||
|
|
||||||
- Goal: align policy docs with their canonical roles.
|
|
||||||
- Files to create/update/delete/move: rewrite
|
|
||||||
`docs/policy/development.md`; lightly update
|
|
||||||
`docs/policy/architecture.md` only if implementation inspection finds
|
|
||||||
inaccuracies; leave `docs/policy/documentation.md` unchanged unless the
|
|
||||||
project intentionally changes policy.
|
|
||||||
- Repository areas to inspect: `go.mod`, package layout, `internal/cli`,
|
|
||||||
`internal/config`, adapters, tests, current documentation policy.
|
|
||||||
- Acceptance criteria: development policy is contributor workflow, not proposed
|
|
||||||
architecture; future/planned/MVP language is removed or moved to roadmap;
|
|
||||||
architecture policy remains principle-level.
|
|
||||||
- Suggested validation commands: grep `docs/policy` for feature-specific
|
|
||||||
future/planned/MVP wording, `go test ./...`.
|
|
||||||
- Prompt size: small enough for one implementation prompt.
|
|
||||||
|
|
||||||
### Stage 6: Examples And Roadmap Cleanup
|
|
||||||
|
|
||||||
- Goal: validate examples and ensure roadmap docs contain only future/planned
|
|
||||||
material.
|
|
||||||
- Files to create/update/delete/move: update `examples/config.yml`; optionally
|
|
||||||
create `examples/minimal-config.yml` with validation coverage; keep
|
|
||||||
`docs/roadmap/future.md` as the future-only roadmap after removing stale
|
|
||||||
implementation-history material.
|
|
||||||
- Repository areas to inspect: `internal/config/*`, config tests,
|
|
||||||
roadmap docs, current implemented feature set.
|
|
||||||
- Acceptance criteria: examples load successfully; roadmap files are clearly
|
|
||||||
future-only; no completed MVP stage plan is linked as current docs.
|
|
||||||
- Suggested validation commands: config example loading test, `go test ./...`,
|
|
||||||
link review.
|
|
||||||
- Prompt size: small enough for one implementation prompt if no new example
|
|
||||||
test helper is needed; otherwise split examples and roadmap cleanup.
|
|
||||||
|
|
||||||
### Stage 7: Final Validation
|
|
||||||
|
|
||||||
- Goal: verify the documentation set is coherent and policy-compliant.
|
|
||||||
- Files to create/update/delete/move: all migrated docs and examples.
|
|
||||||
- Repository areas to inspect: full repository.
|
|
||||||
- Acceptance criteria: non-roadmap docs describe only implemented behavior;
|
|
||||||
canonical homes are respected; links are valid; examples are maintained;
|
|
||||||
stale terminology is absent.
|
|
||||||
- Suggested validation commands: `go test ./...`,
|
|
||||||
`go run ./cmd/weatherreporter --help`, `git diff --check`, stale-term greps,
|
|
||||||
manual link review.
|
|
||||||
- Prompt size: small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Validation Plan
|
|
||||||
|
|
||||||
Automated or semi-automated checks:
|
|
||||||
|
|
||||||
- Run `go test ./...` after documentation migration, especially if examples or
|
|
||||||
config-loading tests change.
|
|
||||||
- Run `go run ./cmd/weatherreporter --help` and compare documented CLI syntax
|
|
||||||
against the output.
|
|
||||||
- Run `git diff --check` for whitespace and patch hygiene.
|
|
||||||
- Search for stale terminology outside roadmap docs:
|
|
||||||
- `--vars-file`
|
|
||||||
- `promptvars`
|
|
||||||
- `weather.tomorrow_report`
|
|
||||||
- `--location home`
|
|
||||||
- `MVP`
|
|
||||||
- `future`
|
|
||||||
- `planned`
|
|
||||||
- `proposed`
|
|
||||||
- Review matches manually. Some policy-level wording such as documentation
|
|
||||||
policy references to future work is legitimate; feature-specific future
|
|
||||||
behavior outside `docs/roadmap/` is not.
|
|
||||||
- Verify README, CLI, config, operations, troubleshooting, internal, and
|
|
||||||
integration links manually. No automated link checker is currently present.
|
|
||||||
- If `examples/minimal-config.yml` is added, add or update a config-loading test
|
|
||||||
so the example remains maintained.
|
|
||||||
- Verify `docs/integrations/weatherapi.md` against
|
|
||||||
`internal/adapters/weatherapi/client.go` so unused endpoints are not
|
|
||||||
documented as current usage.
|
|
||||||
- Verify `docs/integrations/scriptorium.md` against
|
|
||||||
`internal/adapters/scriptorium/runner.go` so argv examples match current
|
|
||||||
subprocess construction.
|
|
||||||
|
|
||||||
Manual review items:
|
|
||||||
|
|
||||||
- Confirm each non-roadmap document has a clear audience and canonical scope.
|
|
||||||
- Confirm current-behavior docs avoid changelog or development-history framing.
|
|
||||||
- Confirm roadmap docs clearly distinguish accepted plans, proposed work,
|
|
||||||
deferred ideas, and rejected ideas where applicable.
|
|
||||||
- Confirm internal docs describe implemented components only.
|
|
||||||
- Confirm operations and troubleshooting do not imply resume, cleanup, archive,
|
|
||||||
remote storage, daemon, or automatic storm monitoring support.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
No questions block a correct documentation roadmap or migration.
|
|
||||||
|
|
||||||
Recommendation: keep `docs/roadmap/future.md` future-only. Do not reintroduce
|
|
||||||
completed implementation-history material as current project documentation.
|
|
||||||
@@ -12,8 +12,7 @@ evaluation remains deferred.
|
|||||||
Proposed direction:
|
Proposed direction:
|
||||||
|
|
||||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||||
weather story context when available, hourly thresholds, and material
|
weather story context, hourly thresholds, and material forecast changes;
|
||||||
forecast changes;
|
|
||||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||||
3. persist storm lifecycle state;
|
3. persist storm lifecycle state;
|
||||||
4. generate or update Storm Reports only when a meaningful event is present;
|
4. generate or update Storm Reports only when a meaningful event is present;
|
||||||
@@ -49,3 +48,38 @@ These ideas are not current behavior:
|
|||||||
|
|
||||||
Each item needs its own design note before implementation. Non-roadmap docs
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
must not describe these as available behavior.
|
must not describe these as available behavior.
|
||||||
|
|
||||||
|
## Deferred: Cleanup Refactors
|
||||||
|
|
||||||
|
The initial cleanup pass intentionally left these refactors out because the
|
||||||
|
current implementation does not yet make them worth the added abstraction.
|
||||||
|
|
||||||
|
Revisit these only when new source types, report types, operational
|
||||||
|
requirements, or recurring maintenance costs make the duplication materially
|
||||||
|
more expensive:
|
||||||
|
|
||||||
|
- Weather API optional-source specification/helper refactor: consider when
|
||||||
|
additional Weather API sources make per-source fan-out, policy handling, and
|
||||||
|
provenance wiring repetitive enough to obscure adapter behavior.
|
||||||
|
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
||||||
|
builders repeatedly derive the same weather signals and tests begin to need
|
||||||
|
coordinated fixture updates.
|
||||||
|
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||||
|
future background workflows gain enough shared step semantics to justify a
|
||||||
|
declared execution model.
|
||||||
|
- Plugin architecture: defer until there is a concrete external extension
|
||||||
|
contract and at least one implemented extension point.
|
||||||
|
- Cobra migration: defer while the standard-library CLI remains small,
|
||||||
|
explicit, and covered by parser tests.
|
||||||
|
- Manifest, resume, or progress system: defer until operators need resumable
|
||||||
|
runs, checkpoint recovery, or richer audit trails than the current durable
|
||||||
|
artifacts and metadata provide.
|
||||||
|
- Global test helper package: defer while package-local helpers keep tests
|
||||||
|
clear; revisit only if setup duplication starts to hide behavior.
|
||||||
|
- Logging subsystem: defer until there are recurring operator diagnostics that
|
||||||
|
cannot be handled with current errors, metadata, inspection commands, and
|
||||||
|
artifact output.
|
||||||
|
|
||||||
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
|
paths, report identities, and adapter boundaries unless a separate roadmap
|
||||||
|
explicitly changes them.
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.rakestrawhome.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 1
|
||||||
units: us
|
units: us
|
||||||
timezone: Chicago
|
timezone: "America/Chicago"
|
||||||
format: json
|
format: json
|
||||||
|
|
||||||
|
location:
|
||||||
|
id: home
|
||||||
|
name: Brentwood
|
||||||
|
region: St. Louis Metro
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
@@ -28,12 +33,15 @@ dayparts:
|
|||||||
end: "06:00"
|
end: "06:00"
|
||||||
- name: morning
|
- name: morning
|
||||||
start: "06:00"
|
start: "06:00"
|
||||||
end: "12:00"
|
end: "10:00"
|
||||||
|
- name: midday
|
||||||
|
start: "10:00"
|
||||||
|
end: "15:00"
|
||||||
- name: afternoon
|
- name: afternoon
|
||||||
start: "12:00"
|
start: "15:00"
|
||||||
end: "18:00"
|
end: "17:00"
|
||||||
- name: evening
|
- name: evening
|
||||||
start: "18:00"
|
start: "17:00"
|
||||||
end: "24:00"
|
end: "24:00"
|
||||||
|
|
||||||
recent_change:
|
recent_change:
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ package scriptorium
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
|||||||
if req.DataPackagePath == "" {
|
if req.DataPackagePath == "" {
|
||||||
return nil, fmt.Errorf("data package path is required")
|
return nil, fmt.Errorf("data package path is required")
|
||||||
}
|
}
|
||||||
binary := r.Binary
|
execution, err := r.execute(ctx, r.renderArgs(req))
|
||||||
if binary == "" {
|
|
||||||
binary = "scriptorium"
|
|
||||||
}
|
|
||||||
commands := r.Commands
|
|
||||||
if commands == nil {
|
|
||||||
commands = ExecRunner{}
|
|
||||||
}
|
|
||||||
args := r.renderArgs(req)
|
|
||||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||||
}
|
}
|
||||||
result := &RenderResult{
|
result := &RenderResult{
|
||||||
Command: append([]string{binary}, args...),
|
Command: execution.argv(),
|
||||||
Stdout: string(commandResult.Stdout),
|
Stdout: string(execution.result.Stdout),
|
||||||
Stderr: string(commandResult.Stderr),
|
Stderr: string(execution.result.Stderr),
|
||||||
StdoutTruncated: commandResult.StdoutTruncated,
|
StdoutTruncated: execution.result.StdoutTruncated,
|
||||||
StderrTruncated: commandResult.StderrTruncated,
|
StderrTruncated: execution.result.StderrTruncated,
|
||||||
ExitCode: commandResult.ExitCode,
|
ExitCode: execution.result.ExitCode,
|
||||||
}
|
}
|
||||||
if commandResult.ExitCode != 0 {
|
if execution.result.ExitCode != 0 {
|
||||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
if req.OutputPath == "" {
|
if req.OutputPath == "" {
|
||||||
return nil, fmt.Errorf("output path is required")
|
return nil, fmt.Errorf("output path is required")
|
||||||
}
|
}
|
||||||
|
execution, err := r.execute(ctx, r.runArgs(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||||
|
}
|
||||||
|
result := &RunResult{
|
||||||
|
Command: execution.argv(),
|
||||||
|
Stdout: string(execution.result.Stdout),
|
||||||
|
Stderr: string(execution.result.Stderr),
|
||||||
|
StdoutTruncated: execution.result.StdoutTruncated,
|
||||||
|
StderrTruncated: execution.result.StderrTruncated,
|
||||||
|
ExitCode: execution.result.ExitCode,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
}
|
||||||
|
if execution.result.ExitCode != 0 {
|
||||||
|
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type execution struct {
|
||||||
|
binary string
|
||||||
|
args []string
|
||||||
|
result CommandResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
||||||
binary := r.Binary
|
binary := r.Binary
|
||||||
if binary == "" {
|
if binary == "" {
|
||||||
binary = "scriptorium"
|
binary = "scriptorium"
|
||||||
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
if commands == nil {
|
if commands == nil {
|
||||||
commands = ExecRunner{}
|
commands = ExecRunner{}
|
||||||
}
|
}
|
||||||
args := r.runArgs(req)
|
result, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
return execution{}, err
|
||||||
}
|
}
|
||||||
result := &RunResult{
|
return execution{binary: binary, args: args, result: result}, nil
|
||||||
Command: append([]string{binary}, args...),
|
}
|
||||||
Stdout: string(commandResult.Stdout),
|
|
||||||
Stderr: string(commandResult.Stderr),
|
func (e execution) argv() []string {
|
||||||
StdoutTruncated: commandResult.StdoutTruncated,
|
return append([]string{e.binary}, e.args...)
|
||||||
StderrTruncated: commandResult.StderrTruncated,
|
|
||||||
ExitCode: commandResult.ExitCode,
|
|
||||||
OutputPath: req.OutputPath,
|
|
||||||
}
|
|
||||||
if commandResult.ExitCode != 0 {
|
|
||||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||||
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveRenderResult(path string, result *RenderResult) error {
|
|
||||||
if result == nil {
|
|
||||||
return fmt.Errorf("render result is required")
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(result, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal render result: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary preflight file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return fmt.Errorf("write temporary preflight file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temporary preflight file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("save preflight %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type limitedBuffer struct {
|
type limitedBuffer struct {
|
||||||
data []byte
|
data []byte
|
||||||
limit int
|
limit int
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"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/forecast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,10 +109,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
|||||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||||
}
|
}
|
||||||
|
if isJSONNull(raw) {
|
||||||
|
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var alerts forecast.AlertRun
|
var alerts forecast.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
@@ -242,6 +246,27 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
|
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||||
|
}
|
||||||
|
var story forecast.WeatherStory
|
||||||
|
if err := decodeSource(raw, &story); err != nil {
|
||||||
|
return b.handleMalformed(&source, err, false)
|
||||||
|
}
|
||||||
|
if !story.StartTime.IsZero() {
|
||||||
|
source.IssuedAt = &story.StartTime
|
||||||
|
}
|
||||||
|
source.UpdatedAt = story.UpdatedAt
|
||||||
|
b.bundle.WeatherStory = &story
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||||
source := forecast.Source{
|
source := forecast.Source{
|
||||||
Name: sourceName,
|
Name: sourceName,
|
||||||
@@ -302,6 +327,8 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
|||||||
type queryOptions struct {
|
type queryOptions struct {
|
||||||
precision bool
|
precision bool
|
||||||
timezone bool
|
timezone bool
|
||||||
|
allowNull bool
|
||||||
|
omitUnits bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -340,7 +367,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
FetchedAt: c.now(),
|
FetchedAt: c.now(),
|
||||||
}
|
}
|
||||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
return nil, source, nil
|
return nil, source, nil
|
||||||
}
|
}
|
||||||
@@ -352,12 +379,18 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
return env.Data, source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isJSONNull(raw json.RawMessage) bool {
|
||||||
|
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||||
reqURL := *c.baseURL
|
reqURL := *c.baseURL
|
||||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||||
query := reqURL.Query()
|
query := reqURL.Query()
|
||||||
query.Set("format", c.format)
|
query.Set("format", c.format)
|
||||||
query.Set("units", c.units)
|
if !opts.omitUnits {
|
||||||
|
query.Set("units", c.units)
|
||||||
|
}
|
||||||
if opts.precision {
|
if opts.precision {
|
||||||
query.Set("precision", strconv.Itoa(c.precision))
|
query.Set("precision", strconv.Itoa(c.precision))
|
||||||
}
|
}
|
||||||
@@ -398,29 +431,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||||
data, err := json.MarshalIndent(bundle, "", " ")
|
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("save bundle: %w", err)
|
||||||
return fmt.Errorf("marshal forecast bundle: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary bundle file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return fmt.Errorf("write temporary bundle file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temporary bundle file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("save bundle %q: %w", path, err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,23 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||||
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
||||||
}
|
}
|
||||||
|
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory.UpdatedAt == nil {
|
||||||
|
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||||
|
}
|
||||||
if len(bundle.Sources) != 8 {
|
if len(bundle.Sources) != 8 {
|
||||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 2 {
|
if len(bundle.Warnings) != 1 {
|
||||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
@@ -55,6 +67,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
||||||
}
|
}
|
||||||
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
|
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -68,11 +83,20 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rawURL := range requested {
|
for _, rawURL := range requested {
|
||||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "format=json") {
|
||||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
t.Fatalf("request %q missing format=json", rawURL)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rawURL, "/weatherstories/") {
|
||||||
|
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
|
||||||
|
t.Fatalf("weather story request %q should use format only", rawURL)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
|
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,16 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if observation.DataSHA256 != want {
|
if observation.DataSHA256 != want {
|
||||||
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
||||||
}
|
}
|
||||||
|
story := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if story.Endpoint != "/weatherstories/latest" {
|
||||||
|
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
|
||||||
|
}
|
||||||
|
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
|
||||||
|
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
|
||||||
|
}
|
||||||
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
|
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
@@ -125,6 +159,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.Alerts == nil {
|
||||||
|
t.Fatal("Alerts = nil, want checked empty alert run")
|
||||||
|
}
|
||||||
|
if len(bundle.Alerts.Alerts) != 0 {
|
||||||
|
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "alerts")
|
||||||
|
if source.Missing {
|
||||||
|
t.Fatalf("alerts source Missing = true, want false")
|
||||||
|
}
|
||||||
|
if source.DataSHA256 == "" {
|
||||||
|
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == "alerts" {
|
||||||
|
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -133,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
|||||||
wantWarns int
|
wantWarns int
|
||||||
wantSource bool
|
wantSource bool
|
||||||
}{
|
}{
|
||||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
||||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
}
|
}
|
||||||
@@ -194,6 +258,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory != nil {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("weather_story source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||||
|
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextCancellation(t *testing.T) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -260,12 +363,13 @@ type handlerOverride struct {
|
|||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fixtures := map[string]string{
|
fixtures := map[string]string{
|
||||||
"/observations": "observations.json",
|
"/observations": "observations.json",
|
||||||
"/conditions/current": "current.json",
|
"/conditions/current": "current.json",
|
||||||
"/forecast/hourly": "hourly.json",
|
"/forecast/hourly": "hourly.json",
|
||||||
"/forecast/narrative": "narrative.json",
|
"/forecast/narrative": "narrative.json",
|
||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
|
"/weatherstories/latest": "weather_story.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
"Warmer temperatures this weekend."
|
"Warmer temperatures this weekend."
|
||||||
],
|
],
|
||||||
"shortTerm": {
|
"shortTerm": {
|
||||||
"title": "Short Term",
|
"qualifier": "(Through This Evening)",
|
||||||
"narrative": "A weak boundary may trigger isolated showers."
|
"text": "A weak boundary may trigger isolated showers."
|
||||||
|
},
|
||||||
|
"longTerm": {
|
||||||
|
"qualifier": "(This Weekend)",
|
||||||
|
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"officeId": "LSX",
|
||||||
|
"startTime": "2026-05-30T08:46:00Z",
|
||||||
|
"endTime": "2026-05-31T11:00:00Z",
|
||||||
|
"updatedAt": "2026-05-30T09:00:34Z",
|
||||||
|
"title": "Several Chances for Rain Through Monday",
|
||||||
|
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
|
||||||
|
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
|
||||||
|
"priority": false,
|
||||||
|
"order": 1,
|
||||||
|
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
@@ -14,6 +12,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"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/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -68,8 +67,6 @@ type BriefingRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyBriefingRequest = BriefingRequest
|
|
||||||
|
|
||||||
type ReportRequest struct {
|
type ReportRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
@@ -78,15 +75,11 @@ type ReportRequest struct {
|
|||||||
Store state.Store
|
Store state.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyReportRequest = ReportRequest
|
|
||||||
|
|
||||||
type BriefingResult struct {
|
type BriefingResult struct {
|
||||||
Package briefing.Package
|
Package briefing.Package
|
||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyBriefingResult = BriefingResult
|
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
Briefing briefing.Package
|
Briefing briefing.Package
|
||||||
BriefingPath string
|
BriefingPath string
|
||||||
@@ -103,8 +96,6 @@ type ReportResult struct {
|
|||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyReportResult = ReportResult
|
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
Batch BatchKind `json:"batch"`
|
Batch BatchKind `json:"batch"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isGeneratedReport(resolved.Definition.ID) {
|
if resolved.Definition.Generated {
|
||||||
_, err := GenerateReport(ctx, ReportRequest{
|
_, err := GenerateReport(ctx, ReportRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, resolved := range resolvedReports {
|
for _, resolved := range resolvedReports {
|
||||||
if !isGeneratedReport(resolved.Definition.ID) {
|
if !resolved.Definition.Generated {
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
func batchOutputPath(outputDir string, definition report.Definition) string {
|
||||||
if outputDir == "" || definition.DefaultOutputName == "" {
|
if outputDir == "" || definition.BatchOutputName == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
|
return filepath.Join(outputDir, definition.BatchOutputName)
|
||||||
return filepath.Join(outputDir, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGeneratedReport(id report.ID) bool {
|
|
||||||
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDailyReport(id report.ID) bool {
|
|
||||||
return id == report.DailyToday || id == report.DailyTomorrow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
|||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
|
|
||||||
return GenerateBriefing(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
||||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
|
|||||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
|
||||||
return GenerateReport(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||||
store := req.Store
|
store := req.Store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
preflightPath := paths.Preflight
|
preflightPath := paths.Preflight
|
||||||
if renderResult != nil {
|
if renderResult != nil {
|
||||||
var err error
|
var err error
|
||||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
|
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
OutputPath: reportPath,
|
OutputPath: reportPath,
|
||||||
})
|
})
|
||||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||||
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
|
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
|
||||||
return BuildBriefing(req, bundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -552,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summary)
|
}, summary)
|
||||||
case report.ThreeDay, report.Weekend:
|
case report.ThreeDay, report.Weekend:
|
||||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||||
@@ -564,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
}
|
}
|
||||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||||
@@ -571,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
case report.Storm:
|
case report.Storm:
|
||||||
return briefing.BuildStorm(briefing.BuildContext{
|
return briefing.BuildStorm(briefing.BuildContext{
|
||||||
@@ -578,18 +551,28 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||||
return state.NewFilesystemStore(cfg.Workspace)
|
location := briefing.LocationContext{
|
||||||
|
ID: cfg.Location.ID,
|
||||||
|
Name: cfg.Location.Name,
|
||||||
|
Region: cfg.Location.Region,
|
||||||
|
Timezone: cfg.WeatherAPI.Timezone,
|
||||||
|
}
|
||||||
|
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &location
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||||
return recentChanges(ctx, store, priorSnapshot, current, cfg)
|
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, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||||
@@ -618,29 +601,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFileAtomic(source string, target string) error {
|
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
||||||
data, err := os.ReadFile(source)
|
if result == nil {
|
||||||
if err != nil {
|
return state.PreflightArtifact{}
|
||||||
return fmt.Errorf("read rendered report %q: %w", source, err)
|
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
return state.PreflightArtifact{
|
||||||
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
Command: append([]string(nil), result.Command...),
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StdoutTruncated: result.StdoutTruncated,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
}
|
}
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary report output file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return fmt.Errorf("write temporary report output file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temporary report output file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, target); err != nil {
|
|
||||||
return fmt.Errorf("save report output %q: %w", target, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -33,7 +34,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"product": "hourly"`) {
|
if !strings.Contains(string(data), `"product": "hourly"`) {
|
||||||
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
|
||||||
|
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||||
@@ -70,11 +76,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportDaily,
|
Report: ReportDaily,
|
||||||
@@ -85,13 +89,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
||||||
|
|
||||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: path,
|
OutputPath: path,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.OutputPath != path {
|
if result.OutputPath != path {
|
||||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
||||||
@@ -108,11 +112,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -123,23 +125,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
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)
|
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -165,14 +165,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if renderer.renderCalls != 1 {
|
if renderer.renderCalls != 1 {
|
||||||
@@ -193,14 +193,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||||
}
|
}
|
||||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} {
|
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(outputPath); err != nil {
|
|
||||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(result.DataPackagePath)
|
data, err := os.ReadFile(result.DataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read data package: %v", err)
|
||||||
@@ -208,6 +201,32 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||||
|
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||||
|
}
|
||||||
preflight, err := os.ReadFile(result.PreflightPath)
|
preflight, err := os.ReadFile(result.PreflightPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read preflight: %v", err)
|
t.Fatalf("read preflight: %v", err)
|
||||||
@@ -236,7 +255,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -259,13 +278,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("GenerateDailyReport() error = nil, want render error")
|
t.Fatal("GenerateReport() error = nil, want render error")
|
||||||
}
|
}
|
||||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,7 +310,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -315,13 +334,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
runBody: "# Daily Report\n",
|
runBody: "# Daily Report\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("GenerateDailyReport() error = nil, want run error")
|
t.Fatal("GenerateReport() error = nil, want run error")
|
||||||
}
|
}
|
||||||
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
||||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||||
@@ -342,7 +361,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -371,6 +390,7 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -393,14 +413,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
|||||||
runBody: "# Daily Report\n",
|
runBody: "# Daily Report\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: currentResolved,
|
Resolved: currentResolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Store: store,
|
Store: store,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(result.RecentChanges) == 0 {
|
if len(result.RecentChanges) == 0 {
|
||||||
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
||||||
@@ -433,13 +453,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
runBody: "# Tomorrow Planning Brief\n",
|
runBody: "# Tomorrow Planning Brief\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
||||||
@@ -485,6 +505,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -506,14 +527,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
runBody: "# Tomorrow Planning Brief\n",
|
runBody: "# Tomorrow Planning Brief\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: currentResolved,
|
Resolved: currentResolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Store: store,
|
Store: store,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.PriorSnapshot == nil {
|
if result.PriorSnapshot == nil {
|
||||||
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
||||||
@@ -551,6 +572,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -626,6 +648,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -882,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/observations":
|
case "/observations":
|
||||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
case "/conditions/current":
|
case "/conditions/current":
|
||||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
||||||
case "/forecast/hourly":
|
case "/forecast/hourly":
|
||||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||||
case "/forecast/narrative":
|
case "/forecast/narrative":
|
||||||
@@ -890,7 +913,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1046,23 @@ func mustParse(value string) time.Time {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPathsExist(t *testing.T, paths ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||||
low := 50.0
|
low := 50.0
|
||||||
high := 58.0
|
high := 58.0
|
||||||
|
|||||||
@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
return inspection.metadata, err
|
||||||
return state.Metadata{}, err
|
|
||||||
}
|
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
|
||||||
return metadata, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return briefing.Package{}, err
|
return briefing.Package{}, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||||
if err != nil {
|
|
||||||
return briefing.Package{}, err
|
|
||||||
}
|
|
||||||
return store.LoadBriefing(ctx, metadata.BriefingPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return promptinput.Package{}, err
|
return promptinput.Package{}, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
||||||
if err != nil {
|
|
||||||
return promptinput.Package{}, err
|
|
||||||
}
|
|
||||||
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
resolved, err := resolvedFromMetadata(inspection.metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolved, err := resolvedFromMetadata(metadata)
|
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return store.FindPriorSnapshot(ctx, resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||||
metadata, err := InspectMetadata(ctx, req)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SourceInspection{}, err
|
return SourceInspection{}, err
|
||||||
}
|
}
|
||||||
|
metadata := inspection.metadata
|
||||||
return SourceInspection{
|
return SourceInspection{
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runInspection struct {
|
||||||
|
store *state.FilesystemStore
|
||||||
|
metadata state.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
|
||||||
|
store, err := defaultStore(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return runInspection{}, err
|
||||||
|
}
|
||||||
|
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return runInspection{}, err
|
||||||
|
}
|
||||||
|
return runInspection{store: store, metadata: metadata}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -57,8 +58,17 @@ type DiscussionContext struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WeatherStoryContext struct {
|
type WeatherStoryContext struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Summary string `json:"summary,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||||
@@ -68,19 +78,18 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
|||||||
if summary == nil {
|
if summary == nil {
|
||||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Daily = &Daily{
|
||||||
Daily: &Daily{
|
BottomLine: buildBottomLine(summary),
|
||||||
BottomLine: buildBottomLine(summary),
|
Dayparts: summary.Dayparts,
|
||||||
Dayparts: summary.Dayparts,
|
RelevantAlerts: summary.AlertOverlaps,
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
NarrativePeriods: summary.NarrativePeriods,
|
||||||
NarrativePeriods: summary.NarrativePeriods,
|
Discussion: buildDiscussion(summary.Discussion),
|
||||||
Discussion: buildDiscussion(summary.Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
ForecastSummaryDate: summary.Date,
|
||||||
ForecastSummaryDate: summary.Date,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||||
}
|
}
|
||||||
@@ -176,9 +185,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
notes = append(notes, "Morning thunder could affect departure timing.")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||||
}
|
}
|
||||||
@@ -203,9 +209,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, prefix+" wintry weather may affect travel.")
|
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||||
}
|
}
|
||||||
@@ -229,9 +232,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
notes = append(notes, "Overnight storms could change morning impacts.")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||||
}
|
}
|
||||||
@@ -259,19 +259,40 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|||||||
KeyMessages: discussion.KeyMessages,
|
KeyMessages: discussion.KeyMessages,
|
||||||
}
|
}
|
||||||
if discussion.ShortTerm != nil {
|
if discussion.ShortTerm != nil {
|
||||||
ctx.ShortTerm = discussion.ShortTerm.Narrative
|
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||||
}
|
}
|
||||||
if discussion.LongTerm != nil {
|
if discussion.LongTerm != nil {
|
||||||
ctx.LongTerm = discussion.LongTerm.Narrative
|
ctx.LongTerm = discussion.LongTerm.Text
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||||
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
|
if bundle == nil || bundle.WeatherStory == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
|
story := bundle.WeatherStory
|
||||||
|
return &WeatherStoryContext{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTime(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||||
@@ -293,10 +314,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
score += float64(len(daypart.AlertOverlaps)) * 100
|
score += float64(len(daypart.AlertOverlaps)) * 100
|
||||||
reasons = append(reasons, "alert overlap")
|
reasons = append(reasons, "alert overlap")
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
score += 75
|
|
||||||
reasons = append(reasons, "thunder risk")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||||
score += 25
|
score += 25
|
||||||
if daypart.Indicators.Heat {
|
if daypart.Indicators.Heat {
|
||||||
@@ -334,9 +351,6 @@ func bottomLineText(conditions []string, hazards []string) string {
|
|||||||
|
|
||||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||||
var hazards []string
|
var hazards []string
|
||||||
if indicators.Thunder {
|
|
||||||
hazards = append(hazards, "thunder")
|
|
||||||
}
|
|
||||||
if indicators.Snow {
|
if indicators.Snow {
|
||||||
hazards = append(hazards, "snow")
|
hazards = append(hazards, "snow")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import (
|
|||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||||
bundle := loadBundleFixture(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.Sources[0].DataSHA256 = "abc123"
|
||||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||||
location := mustLocation(t)
|
location := mustLocation(t)
|
||||||
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
}, summary)
|
}, summary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
t.Fatalf("BuildDaily() error = %v", err)
|
||||||
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
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" {
|
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||||
}
|
}
|
||||||
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Daily == nil {
|
if pkg.Daily == nil {
|
||||||
t.Fatal("Daily = nil")
|
t.Fatal("Daily = nil")
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.Dayparts) != 4 {
|
if len(pkg.Daily.Dayparts) != 5 {
|
||||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||||
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
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 {
|
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||||
}
|
}
|
||||||
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||||
}},
|
}},
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
Alerts: &forecast.AlertRun{},
|
||||||
|
Sources: []forecast.Source{
|
||||||
|
{Name: "hourly", FetchedAt: time.Now()},
|
||||||
|
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||||
|
{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())
|
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
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 {
|
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
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) {
|
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||||
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
|||||||
Value: wind,
|
Value: wind,
|
||||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true},
|
Indicators: forecast.Indicators{Snow: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -237,9 +293,10 @@ func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
|||||||
func defaultDayparts() []forecast.DaypartDefinition {
|
func defaultDayparts() []forecast.DaypartDefinition {
|
||||||
return []forecast.DaypartDefinition{
|
return []forecast.DaypartDefinition{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -16,11 +14,12 @@ import (
|
|||||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||||
|
|
||||||
type Package struct {
|
type Package struct {
|
||||||
Metadata Metadata `json:"metadata"`
|
Metadata Metadata `json:"metadata"`
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
Daily *Daily `json:"daily,omitempty"`
|
||||||
Weekend *Weekend `json:"weekend,omitempty"`
|
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||||
Storm *Storm `json:"storm,omitempty"`
|
Weekend *Weekend `json:"weekend,omitempty"`
|
||||||
|
Storm *Storm `json:"storm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
@@ -33,10 +32,34 @@ type Metadata struct {
|
|||||||
Units string `json:"units"`
|
Units string `json:"units"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
|
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationContext struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
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 {
|
type SourceMetadata struct {
|
||||||
@@ -50,11 +73,19 @@ type SourceMetadata struct {
|
|||||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlertStatus struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"activeCount"`
|
||||||
|
RelevantCount int `json:"relevantCount"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type BuildContext struct {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *forecast.Bundle
|
Bundle *forecast.Bundle
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
Location *LocationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(ctx BuildContext) Metadata {
|
func BuildMetadata(ctx BuildContext) Metadata {
|
||||||
@@ -70,37 +101,85 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Units: ctx.Units,
|
Units: ctx.Units,
|
||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
SourceLocationID: sourceLocationID,
|
SourceLocationID: sourceLocationID,
|
||||||
SourceLocation: sourceLocation,
|
SourceLocation: sourceLocation,
|
||||||
Sources: sourceMetadata(ctx.Bundle),
|
Sources: sourceMetadata(ctx.Bundle),
|
||||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||||
|
Alerts: alertStatus(ctx.Bundle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Save(path string, pkg Package) error {
|
func buildPackage(ctx BuildContext) Package {
|
||||||
data, err := json.MarshalIndent(pkg, "", " ")
|
return Package{
|
||||||
if err != nil {
|
Metadata: BuildMetadata(ctx),
|
||||||
return fmt.Errorf("marshal briefing package: %w", err)
|
CurrentConditions: currentConditions(ctx.Bundle),
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
}
|
||||||
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary briefing file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
func copyLocation(location *LocationContext) *LocationContext {
|
||||||
tmp.Close()
|
if location == nil {
|
||||||
return fmt.Errorf("write temporary briefing file: %w", err)
|
return nil
|
||||||
}
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
copied := *location
|
||||||
return fmt.Errorf("close temporary briefing file: %w", err)
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||||
|
if bundle == nil || bundle.Current == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
current := bundle.Current
|
||||||
return fmt.Errorf("save briefing %q: %w", path, err)
|
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
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFloat(value *float64) *float64 {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
|||||||
return bundle.Warnings
|
return bundle.Warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||||
|
if bundle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status := &AlertStatus{}
|
||||||
|
if bundle.Alerts != nil {
|
||||||
|
status.Checked = true
|
||||||
|
status.ActiveCount = len(bundle.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
for _, source := range bundle.Sources {
|
||||||
|
if source.Name == "alerts" && source.Missing {
|
||||||
|
status.Missing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !status.Checked && !status.Missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRelevantAlertCount(metadata *Metadata, count int) {
|
||||||
|
if metadata.Alerts == nil {
|
||||||
|
if count == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata.Alerts = &AlertStatus{}
|
||||||
|
}
|
||||||
|
metadata.Alerts.RelevantCount = count
|
||||||
|
}
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
|||||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
}
|
}
|
||||||
return Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Storm = storm
|
||||||
Storm: storm,
|
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||||
}, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||||
@@ -139,9 +139,6 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
|
|||||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if summary.Indicators.Thunder {
|
|
||||||
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
|
|
||||||
}
|
|
||||||
if summary.Indicators.Wind {
|
if summary.Indicators.Wind {
|
||||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||||
}
|
}
|
||||||
@@ -161,12 +158,16 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
|||||||
}
|
}
|
||||||
if bundle.Discussion != nil {
|
if bundle.Discussion != nil {
|
||||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
|
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
|
if bundle.WeatherStory != nil {
|
||||||
items = appendUnique(items, "Weather story source is available.")
|
if bundle.WeatherStory.Title != "" {
|
||||||
|
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
||||||
|
} else {
|
||||||
|
items = appendUnique(items, "Weather story source is available.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, warning := range bundle.Warnings {
|
for _, warning := range bundle.Warnings {
|
||||||
if warning.Code != "" {
|
if warning.Code != "" {
|
||||||
|
|||||||
@@ -44,9 +44,22 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
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"}`),
|
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."}},
|
Discussion: &forecast.Discussion{
|
||||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
Product: "discussion",
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
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"})
|
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||||
@@ -75,6 +88,15 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
if pkg.Storm.WeatherStory == nil {
|
if pkg.Storm.WeatherStory == nil {
|
||||||
t.Fatal("WeatherStory = nil, want available story context")
|
t.Fatal("WeatherStory = nil, want available story context")
|
||||||
}
|
}
|
||||||
|
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
||||||
|
}
|
||||||
|
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||||
|
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 {
|
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.ThreeDay = &ThreeDay{
|
||||||
ThreeDay: &ThreeDay{
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
day := buildOutlookDay(summary)
|
day := buildOutlookDay(summary)
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||||
}
|
}
|
||||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
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",
|
Date: "2026-05-30",
|
||||||
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||||
}
|
}
|
||||||
first := pkg.ThreeDay.Days[0]
|
first := pkg.ThreeDay.Days[0]
|
||||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||||
}
|
}
|
||||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Weekend = &Weekend{
|
||||||
Weekend: &Weekend{
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||||
}
|
}
|
||||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
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)
|
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
|
|||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
notes = append(notes, label+" thunder risk is present.")
|
|
||||||
}
|
|
||||||
return notes
|
return notes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
HourlyPeriods: []forecast.ForecastPeriod{
|
HourlyPeriods: []forecast.ForecastPeriod{
|
||||||
{
|
{
|
||||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||||
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
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."},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +84,16 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||||
}
|
}
|
||||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
previous bool
|
previous bool
|
||||||
current bool
|
current bool
|
||||||
}{
|
}{
|
||||||
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
|
||||||
{name: "snow", previous: previous.Snow, current: current.Snow},
|
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||||
{name: "ice", previous: previous.Ice, current: current.Ice},
|
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||||
} {
|
} {
|
||||||
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||||
out := forecast.Indicators{}
|
out := forecast.Indicators{}
|
||||||
for _, daypart := range dayparts {
|
for _, daypart := range dayparts {
|
||||||
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
|
||||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
|||||||
|
|
||||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
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{Thunder: true})
|
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CompareDaily() error = %v", err)
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
}
|
}
|
||||||
if countType(changes, "thunder_risk_change") != 1 {
|
if countType(changes, "snow_risk_change") != 1 {
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
Value: currentPrecip,
|
Value: currentPrecip,
|
||||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +51,16 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||||
}
|
}
|
||||||
var foundPrecip bool
|
var foundPrecip bool
|
||||||
var foundThunder bool
|
var foundSnow bool
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "outlook_precip_probability_change" {
|
if change.Type == "outlook_precip_probability_change" {
|
||||||
foundPrecip = true
|
foundPrecip = true
|
||||||
}
|
}
|
||||||
if change.Type == "outlook_thunder_risk_change" {
|
if change.Type == "outlook_snow_risk_change" {
|
||||||
foundThunder = true
|
foundSnow = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !foundPrecip || !foundThunder {
|
if !foundPrecip || !foundSnow {
|
||||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||||
Date: "2026-05-30",
|
Date: "2026-05-30",
|
||||||
Temperature: forecast.Range{Max: ¤tTemp},
|
Temperature: forecast.Range{Max: ¤tTemp},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want weekend changes")
|
t.Fatal("changes length = 0, want weekend changes")
|
||||||
}
|
}
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
result, err := app.RunBatchDetailed(ctx, req)
|
result, err := app.RunBatchDetailed(ctx, req)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
writeRunLogs(stderr, result)
|
writeRunLogs(stderr, result)
|
||||||
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
|
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||||
return encodeErr
|
return encodeErr
|
||||||
}
|
}
|
||||||
if result.Failed > 0 {
|
if result.Failed > 0 {
|
||||||
@@ -108,6 +109,29 @@ type inspectOptions struct {
|
|||||||
RunID string
|
RunID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type inspectRunCommand struct {
|
||||||
|
Name string
|
||||||
|
Inspect func(context.Context, app.InspectRunRequest) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
|
return app.InspectDataPackage(ctx, req)
|
||||||
|
}},
|
||||||
|
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
|
return app.InspectPriorSnapshot(ctx, req)
|
||||||
|
}},
|
||||||
|
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
|
return app.InspectSources(ctx, req)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
|
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("inspect requires a command")
|
return fmt.Errorf("inspect requires a command")
|
||||||
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeJSON(stdout, records)
|
return writeJSON(stdout, records)
|
||||||
case "metadata":
|
|
||||||
opts, err := parseInspectRunFlags(command, args[1:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(stdout, metadata)
|
|
||||||
case "briefing":
|
|
||||||
opts, err := parseInspectRunFlags(command, args[1:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(stdout, pkg)
|
|
||||||
case "data-package":
|
|
||||||
opts, err := parseInspectRunFlags(command, args[1:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(stdout, pkg)
|
|
||||||
case "prior":
|
|
||||||
opts, err := parseInspectRunFlags(command, args[1:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(stdout, prior)
|
|
||||||
case "sources":
|
|
||||||
opts, err := parseInspectRunFlags(command, args[1:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(stdout, sources)
|
|
||||||
default:
|
default:
|
||||||
|
for _, candidate := range inspectRunCommands {
|
||||||
|
if candidate.Name == command {
|
||||||
|
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("unknown inspect command %q", command)
|
return fmt.Errorf("unknown inspect command %q", command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
|
||||||
|
opts, err := parseInspectRunFlags(command.Name, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, value)
|
||||||
|
}
|
||||||
|
|
||||||
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
report, ok := reportKind(args[0])
|
reportKind, ok := reportKind(args[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
opts, err := parseGenerateFlags(report, args[1:])
|
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
Path: opts.ConfigPath,
|
Path: opts.ConfigPath,
|
||||||
Units: opts.Units,
|
Units: opts.Units,
|
||||||
Timezone: opts.Timezone,
|
Timezone: opts.Timezone,
|
||||||
Output: opts.Output,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
|
|
||||||
req := app.GenerateRequest{
|
req := app.GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: report,
|
Report: reportKind,
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
Now: r.Clock.Now(),
|
Now: r.Clock.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch report {
|
switch reportKind {
|
||||||
case app.ReportDaily:
|
case app.ReportDaily:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if opts.End == "" {
|
if opts.End == "" {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
||||||
}
|
}
|
||||||
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
|
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
}
|
}
|
||||||
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
|
req.StormStart = period.Start
|
||||||
if err != nil {
|
req.StormEnd = period.End
|
||||||
return app.GenerateRequest{}, err
|
|
||||||
}
|
|
||||||
if !req.StormEnd.After(req.StormStart) {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
|
|
||||||
encoder := json.NewEncoder(stdout)
|
|
||||||
encoder.SetIndent("", " ")
|
|
||||||
return encoder.Encode(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(stdout io.Writer, value any) error {
|
func writeJSON(stdout io.Writer, value any) error {
|
||||||
encoder := json.NewEncoder(stdout)
|
encoder := json.NewEncoder(stdout)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
|
|||||||
@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
configPath := filepath.Join(tempDir, "config.yml")
|
|
||||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
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 + "\n"
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
outPath := filepath.Join(tempDir, "storm.md")
|
outPath := filepath.Join(tempDir, "storm.md")
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
||||||
if err != nil {
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
t.Fatalf("glob data package: %v", err)
|
|
||||||
}
|
|
||||||
if len(dataPackageMatches) != 1 {
|
|
||||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(dataPackageMatches[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
configPath := filepath.Join(tempDir, "config.yml")
|
|
||||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
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 + "\n"
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
outPath := filepath.Join(tempDir, "tomorrow.md")
|
outPath := filepath.Join(tempDir, "tomorrow.md")
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
||||||
if err != nil {
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
t.Fatalf("glob data package: %v", err)
|
|
||||||
}
|
|
||||||
if len(dataPackageMatches) != 1 {
|
|
||||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(dataPackageMatches[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -457,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
"generate", "daily",
|
"generate", "daily",
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"--date", "2026-05-29",
|
"--date", "2026-05-29",
|
||||||
|
"--tz", "UTC",
|
||||||
"--out", outPath,
|
"--out", outPath,
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -483,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
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)
|
||||||
|
}
|
||||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob preflight: %v", err)
|
t.Fatalf("glob preflight: %v", err)
|
||||||
@@ -581,6 +580,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tempDir, "config.yml")
|
||||||
|
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
|
||||||
|
|
||||||
|
for _, command := range commands {
|
||||||
|
t.Run(command+" requires run id", func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want missing run id error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requires a run id") {
|
||||||
|
t.Fatalf("error = %q, want missing run id context", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(command+" accepts config", func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want missing metadata error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "metadata for run id") {
|
||||||
|
t.Fatalf("error = %q, want missing metadata context", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateCommands(t *testing.T) {
|
func TestResolveGenerateCommands(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -643,7 +681,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
|||||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveGenerate() error = nil, want missing start error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requires --start") {
|
||||||
|
t.Fatalf("error = %q, want missing start", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
||||||
}
|
}
|
||||||
@@ -652,6 +698,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
req, err := runner.resolveGenerate([]string{
|
||||||
|
"storm",
|
||||||
|
"--tz", "America/Chicago",
|
||||||
|
"--start", "2026-05-29T18:00",
|
||||||
|
"--end", "2026-05-30T06:00",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
|
||||||
|
t.Fatalf("StormStart = %q, want local Chicago time", got)
|
||||||
|
}
|
||||||
|
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
|
||||||
|
t.Fatalf("StormEnd = %q, want local Chicago time", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -668,6 +734,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
_, err := runner.resolveGenerate([]string{
|
||||||
|
"storm",
|
||||||
|
"--start", "2026-05-30T06:00",
|
||||||
|
"--end", "2026-05-29T18:00",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "end time after start time") {
|
||||||
|
t.Fatalf("error = %q, want invalid bounds context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveRunCommands(t *testing.T) {
|
func TestResolveRunCommands(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -731,6 +813,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -739,6 +823,28 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
|
||||||
|
t.Helper()
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneArtifact(t *testing.T, root string, parts ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob artifact: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("artifact matches = %#v, want one", matches)
|
||||||
|
}
|
||||||
|
return matches[0]
|
||||||
|
}
|
||||||
|
|
||||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ const (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
|
Location LocationConfig `yaml:"location"`
|
||||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
Reports ReportOutputConfig `yaml:"reports"`
|
|
||||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,12 @@ type WeatherAPIConfig struct {
|
|||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocationConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
Default MissingSourcePolicy `yaml:"default"`
|
Default MissingSourcePolicy `yaml:"default"`
|
||||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||||
@@ -52,11 +58,6 @@ type WorkspaceConfig struct {
|
|||||||
PreflightDir string `yaml:"preflight_dir"`
|
PreflightDir string `yaml:"preflight_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportOutputConfig struct {
|
|
||||||
OutputDir string `yaml:"output_dir"`
|
|
||||||
Paths map[string]string `yaml:"paths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DaypartConfig struct {
|
type DaypartConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Start string `yaml:"start"`
|
Start string `yaml:"start"`
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Units != "us" {
|
if cfg.WeatherAPI.Units != "us" {
|
||||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
if cfg.WeatherAPI.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Format != "json" {
|
if cfg.WeatherAPI.Format != "json" {
|
||||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||||
}
|
}
|
||||||
|
if cfg.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.MissingSource.Default != MissingSourceWarn {
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,8 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
|
||||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||||
@@ -43,6 +46,9 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
||||||
}
|
}
|
||||||
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
|
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||||
@@ -63,6 +69,9 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
|||||||
if cfg.Workspace.Root != "workspace" {
|
if cfg.Workspace.Root != "workspace" {
|
||||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||||
}
|
}
|
||||||
|
if cfg.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
@@ -92,7 +101,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAppliesOverrides(t *testing.T) {
|
func TestLoadAppliesOverrides(t *testing.T) {
|
||||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
|
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -102,7 +111,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Timezone != "+09:30" {
|
if cfg.WeatherAPI.Timezone != "+09:30" {
|
||||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
if cfg.Reports.OutputDir != "./out" {
|
|
||||||
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ func Defaults() Config {
|
|||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Precision: 1,
|
Precision: 1,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "Chicago",
|
Timezone: "America/Chicago",
|
||||||
Format: "json",
|
Format: "json",
|
||||||
},
|
},
|
||||||
|
Location: LocationConfig{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
},
|
||||||
MissingSource: MissingSourceConfig{
|
MissingSource: MissingSourceConfig{
|
||||||
Default: MissingSourceWarn,
|
Default: MissingSourceWarn,
|
||||||
Sources: map[string]MissingSourcePolicy{},
|
Sources: map[string]MissingSourcePolicy{},
|
||||||
@@ -28,15 +33,12 @@ func Defaults() Config {
|
|||||||
DataPackagesDir: "data-packages",
|
DataPackagesDir: "data-packages",
|
||||||
PreflightDir: "preflight",
|
PreflightDir: "preflight",
|
||||||
},
|
},
|
||||||
Reports: ReportOutputConfig{
|
|
||||||
OutputDir: "reports",
|
|
||||||
Paths: map[string]string{},
|
|
||||||
},
|
|
||||||
Dayparts: []DaypartConfig{
|
Dayparts: []DaypartConfig{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
},
|
},
|
||||||
RecentChange: RecentChangeConfig{
|
RecentChange: RecentChangeConfig{
|
||||||
TemperatureDegrees: 5,
|
TemperatureDegrees: 5,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ type LoadOptions struct {
|
|||||||
Path string
|
Path string
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
Output string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(opts LoadOptions) (Config, error) {
|
func Load(opts LoadOptions) (Config, error) {
|
||||||
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
|
|||||||
if opts.Timezone != "" {
|
if opts.Timezone != "" {
|
||||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||||
}
|
}
|
||||||
if opts.Output != "" {
|
|
||||||
cfg.Reports.OutputDir = opts.Output
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
|
|||||||
if cfg.MissingSource.Sources == nil {
|
if cfg.MissingSource.Sources == nil {
|
||||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||||
}
|
}
|
||||||
if cfg.Reports.Paths == nil {
|
|
||||||
cfg.Reports.Paths = map[string]string{}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
|
|||||||
if cfg.Workspace.Root == "" {
|
if cfg.Workspace.Root == "" {
|
||||||
return fmt.Errorf("workspace.root is required")
|
return fmt.Errorf("workspace.root is required")
|
||||||
}
|
}
|
||||||
if cfg.Reports.OutputDir == "" {
|
|
||||||
return fmt.Errorf("reports.output_dir is required")
|
|
||||||
}
|
|
||||||
if len(cfg.Dayparts) == 0 {
|
if len(cfg.Dayparts) == 0 {
|
||||||
return fmt.Errorf("dayparts must contain at least one entry")
|
return fmt.Errorf("dayparts must contain at least one entry")
|
||||||
}
|
}
|
||||||
|
|||||||
48
internal/fileutil/fileutil.go
Normal file
48
internal/fileutil/fileutil.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Package fileutil provides narrow filesystem helpers for durable artifacts.
|
||||||
|
package fileutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteFileAtomic(path string, data []byte) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary file for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("write temporary file for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temporary file for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("save %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteJSONAtomic(path string, value any) error {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return WriteFileAtomic(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyFileAtomic(source string, target string) error {
|
||||||
|
data, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %q: %w", source, err)
|
||||||
|
}
|
||||||
|
return WriteFileAtomic(target, data)
|
||||||
|
}
|
||||||
103
internal/fileutil/fileutil_test.go
Normal file
103
internal/fileutil/fileutil_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package fileutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||||
|
|
||||||
|
if err := WriteFileAtomic(path, []byte("artifact")); err != nil {
|
||||||
|
t.Fatalf("WriteFileAtomic() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "artifact" {
|
||||||
|
t.Fatalf("data = %q, want artifact", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
|
if err := WriteFileAtomic(path, []byte("old")); err != nil {
|
||||||
|
t.Fatalf("WriteFileAtomic() initial error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteFileAtomic(path, []byte("new")); err != nil {
|
||||||
|
t.Fatalf("WriteFileAtomic() overwrite error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "new" {
|
||||||
|
t.Fatalf("data = %q, want new", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "target")
|
||||||
|
if err := os.Mkdir(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("Mkdir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := WriteFileAtomic(target, []byte("data"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("WriteFileAtomic() error = nil, want rename error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "save") {
|
||||||
|
t.Fatalf("error = %q, want save context", err.Error())
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Glob() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("temporary files = %v, want none", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteJSONAtomic(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "artifact.json")
|
||||||
|
|
||||||
|
if err := WriteJSONAtomic(path, map[string]string{"status": "ok"}); err != nil {
|
||||||
|
t.Fatalf("WriteJSONAtomic() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "{\n \"status\": \"ok\"\n}" {
|
||||||
|
t.Fatalf("json = %q, want indented object", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFileAtomic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source.txt")
|
||||||
|
target := filepath.Join(dir, "nested", "target.txt")
|
||||||
|
if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CopyFileAtomic(source, target); err != nil {
|
||||||
|
t.Fatalf("CopyFileAtomic() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "copied" {
|
||||||
|
t.Fatalf("data = %q, want copied", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,13 +149,20 @@ type Discussion struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscussionSection struct {
|
type DiscussionSection struct {
|
||||||
Title string `json:"title,omitempty"`
|
Qualifier string `json:"qualifier,omitempty"`
|
||||||
Narrative string `json:"narrative,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeatherStory struct {
|
type WeatherStory struct {
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
StartTime time.Time `json:"startTime"`
|
||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
EndTime time.Time `json:"endTime"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,12 @@ type TimedValue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Thunder bool `json:"thunder,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Fog bool `json:"fog,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
Heat bool `json:"heat,omitempty"`
|
||||||
Heat bool `json:"heat,omitempty"`
|
Cold bool `json:"cold,omitempty"`
|
||||||
Cold bool `json:"cold,omitempty"`
|
Wind bool `json:"wind,omitempty"`
|
||||||
Wind bool `json:"wind,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertOverlap struct {
|
type AlertOverlap struct {
|
||||||
@@ -291,11 +290,10 @@ func sortedKeys(values map[string]struct{}) []string {
|
|||||||
func indicatorsForText(text string) Indicators {
|
func indicatorsForText(text string) Indicators {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
|
Snow: strings.Contains(lower, "snow"),
|
||||||
Snow: strings.Contains(lower, "snow"),
|
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
Fog: strings.Contains(lower, "fog"),
|
||||||
Fog: strings.Contains(lower, "fog"),
|
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
||||||
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,13 +316,12 @@ func numericIndicators(period ForecastPeriod) Indicators {
|
|||||||
|
|
||||||
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: left.Thunder || right.Thunder,
|
Snow: left.Snow || right.Snow,
|
||||||
Snow: left.Snow || right.Snow,
|
Ice: left.Ice || right.Ice,
|
||||||
Ice: left.Ice || right.Ice,
|
Fog: left.Fog || right.Fog,
|
||||||
Fog: left.Fog || right.Fog,
|
Heat: left.Heat || right.Heat,
|
||||||
Heat: left.Heat || right.Heat,
|
Cold: left.Cold || right.Cold,
|
||||||
Cold: left.Cold || right.Cold,
|
Wind: left.Wind || right.Wind,
|
||||||
Wind: left.Wind || right.Wind,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
|||||||
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
||||||
}
|
}
|
||||||
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
||||||
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
|
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
|
||||||
}
|
}
|
||||||
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
|
if !morning.Indicators.Wind {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
|
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
||||||
}
|
}
|
||||||
|
|
||||||
afternoon := summary.Dayparts[2]
|
afternoon := summary.Dayparts[2]
|
||||||
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|||||||
if len(summary.Dayparts) != 2 {
|
if len(summary.Dayparts) != 2 {
|
||||||
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
||||||
}
|
}
|
||||||
if !summary.Dayparts[0].Indicators.Thunder {
|
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
||||||
}
|
}
|
||||||
if len(summary.AlertOverlaps) != 1 {
|
if len(summary.AlertOverlaps) != 1 {
|
||||||
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
||||||
|
|||||||
10
internal/forecast/testdata/daily_bundle.json
vendored
10
internal/forecast/testdata/daily_bundle.json
vendored
@@ -54,7 +54,15 @@
|
|||||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||||
"keyMessages": [
|
"keyMessages": [
|
||||||
"Storms are most likely during the morning."
|
"Storms are most likely during the morning."
|
||||||
]
|
],
|
||||||
|
"shortTerm": {
|
||||||
|
"qualifier": "(Through This Evening)",
|
||||||
|
"text": "Morning showers taper as a weak boundary shifts east."
|
||||||
|
},
|
||||||
|
"longTerm": {
|
||||||
|
"qualifier": "(This Weekend)",
|
||||||
|
"text": "Warmer and more humid conditions return with periodic rain chances."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
package promptinput
|
package promptinput
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"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/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -27,12 +25,13 @@ type Package struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Report struct {
|
type Report struct {
|
||||||
ID report.ID `json:"id"`
|
ID report.ID `json:"id"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
CurrentLocalDate string `json:"currentLocalDate"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecentChanges struct {
|
type RecentChanges struct {
|
||||||
@@ -44,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||||
|
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
items := make([]changes.Change, len(recentChanges))
|
items := make([]changes.Change, len(recentChanges))
|
||||||
copy(items, recentChanges)
|
copy(items, recentChanges)
|
||||||
if items == nil {
|
if items == nil {
|
||||||
@@ -53,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: SchemaVersion,
|
||||||
RunID: briefingPackage.Metadata.RunID,
|
RunID: briefingPackage.Metadata.RunID,
|
||||||
Report: Report{
|
Report: Report{
|
||||||
ID: briefingPackage.Metadata.ReportID,
|
ID: briefingPackage.Metadata.ReportID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingPackage.Metadata.Variant,
|
||||||
PromptID: briefingPackage.Metadata.PromptID,
|
PromptID: briefingPackage.Metadata.PromptID,
|
||||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||||
Timezone: briefingPackage.Metadata.Timezone,
|
Timezone: briefingPackage.Metadata.Timezone,
|
||||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
CurrentLocalDate: localDate,
|
||||||
|
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||||
},
|
},
|
||||||
Briefing: briefingPackage,
|
Briefing: briefingPackage,
|
||||||
RecentChanges: RecentChanges{Items: items},
|
RecentChanges: RecentChanges{Items: items},
|
||||||
@@ -70,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
|
||||||
|
}
|
||||||
|
return generatedAt.In(location).Format(timeutil.DateLayout), nil
|
||||||
|
}
|
||||||
|
|
||||||
func Validate(pkg Package) error {
|
func Validate(pkg Package) error {
|
||||||
if pkg.SchemaVersion == "" {
|
if pkg.SchemaVersion == "" {
|
||||||
return fmt.Errorf("schemaVersion is required")
|
return fmt.Errorf("schemaVersion is required")
|
||||||
@@ -89,6 +101,9 @@ func Validate(pkg Package) error {
|
|||||||
if pkg.Report.Timezone == "" {
|
if pkg.Report.Timezone == "" {
|
||||||
return fmt.Errorf("report.timezone is required")
|
return fmt.Errorf("report.timezone is required")
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate == "" {
|
||||||
|
return fmt.Errorf("report.currentLocalDate is required")
|
||||||
|
}
|
||||||
if !pkg.Report.ValidPeriod.IsValid() {
|
if !pkg.Report.ValidPeriod.IsValid() {
|
||||||
return fmt.Errorf("report.validPeriod must be valid")
|
return fmt.Errorf("report.validPeriod must be valid")
|
||||||
}
|
}
|
||||||
@@ -117,29 +132,8 @@ func Save(path string, pkg Package) error {
|
|||||||
if err := Validate(pkg); err != nil {
|
if err := Validate(pkg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(pkg, "", " ")
|
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("save data package: %w", err)
|
||||||
return fmt.Errorf("marshal data package: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary data package file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return fmt.Errorf("write temporary data package file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temporary data package file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("save data package %q: %w", path, err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_report" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
if pkg.Briefing.Daily == nil {
|
if pkg.Briefing.Daily == nil {
|
||||||
t.Fatal("Briefing.Daily = nil")
|
t.Fatal("Briefing.Daily = nil")
|
||||||
}
|
}
|
||||||
|
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 pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
||||||
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
pkg, err := Build(briefingPackage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
|
||||||
|
briefingPackage := validBriefingPackage()
|
||||||
|
briefingPackage.Metadata.Timezone = "Not/AZone"
|
||||||
|
|
||||||
|
_, err := Build(briefingPackage)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Build() error = nil, want invalid timezone error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "report timezone") {
|
||||||
|
t.Fatalf("error = %q, want report timezone context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateRequiresFields(t *testing.T) {
|
func TestValidateRequiresFields(t *testing.T) {
|
||||||
pkg, err := Build(validBriefingPackage())
|
pkg, err := Build(validBriefingPackage())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||||
|
pkg, err := Build(validBriefingPackage())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
pkg.Report.CurrentLocalDate = ""
|
||||||
|
|
||||||
|
err = Validate(pkg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want required field error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "currentLocalDate") {
|
||||||
|
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildThreeDayDataPackage(t *testing.T) {
|
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||||
briefingPackage := validBriefingPackage()
|
briefingPackage := validBriefingPackage()
|
||||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||||
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
|
|||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||||
End: time.Date(2026, 5, 30, 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{
|
Daily: &briefing.Daily{
|
||||||
ForecastSummaryDate: "2026-05-29",
|
ForecastSummaryDate: "2026-05-29",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ type Definition struct {
|
|||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
DefaultOutputName string
|
ArtifactGroup string
|
||||||
|
BatchOutputName string
|
||||||
|
Generated bool
|
||||||
|
CompatiblePriorIDs []ID
|
||||||
Morning bool
|
Morning bool
|
||||||
Evening bool
|
Evening bool
|
||||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||||
@@ -51,6 +54,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
|||||||
return d.resolve(req)
|
return d.resolve(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d Definition) CompatibleWithPrior(id ID) bool {
|
||||||
|
for _, compatibleID := range d.CompatiblePriorIDs {
|
||||||
|
if id == compatibleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type ResolveRequest struct {
|
type ResolveRequest struct {
|
||||||
Now time.Time
|
Now time.Time
|
||||||
Location *time.Location
|
Location *time.Location
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
id ID
|
||||||
|
artifactGroup string
|
||||||
|
batchOutputName string
|
||||||
|
generated bool
|
||||||
|
compatiblePriorIDs []ID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
id: DailyToday,
|
||||||
|
artifactGroup: "daily",
|
||||||
|
batchOutputName: "daily.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DailyTomorrow,
|
||||||
|
artifactGroup: "daily",
|
||||||
|
batchOutputName: "tomorrow.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ThreeDay,
|
||||||
|
artifactGroup: "three-day",
|
||||||
|
batchOutputName: "three-day.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Weekend,
|
||||||
|
artifactGroup: "weekend",
|
||||||
|
batchOutputName: "weekend.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Weekend},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Storm,
|
||||||
|
artifactGroup: "storm",
|
||||||
|
batchOutputName: "storm.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Storm},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 definition.ArtifactGroup != tt.artifactGroup {
|
||||||
|
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
|
||||||
|
}
|
||||||
|
if definition.BatchOutputName != tt.batchOutputName {
|
||||||
|
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
|
||||||
|
}
|
||||||
|
if definition.Generated != tt.generated {
|
||||||
|
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||||
|
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||||
|
}
|
||||||
|
for _, id := range tt.compatiblePriorIDs {
|
||||||
|
if !definition.CompatibleWithPrior(id) {
|
||||||
|
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolvedMetadata(t *testing.T) {
|
func TestResolvedMetadata(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "daily.md",
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveDailyToday,
|
resolve: resolveDailyToday,
|
||||||
},
|
},
|
||||||
@@ -22,7 +25,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Tomorrow Planning Brief",
|
Name: "Tomorrow Planning Brief",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "tomorrow.md",
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "tomorrow.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
Evening: true,
|
Evening: true,
|
||||||
resolve: resolveDailyTomorrow,
|
resolve: resolveDailyTomorrow,
|
||||||
},
|
},
|
||||||
@@ -31,7 +37,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "3-Day Outlook",
|
Name: "3-Day Outlook",
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.three_day_outlook",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "three_day.md",
|
ArtifactGroup: "three-day",
|
||||||
|
BatchOutputName: "three-day.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{ThreeDay},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveThreeDay,
|
resolve: resolveThreeDay,
|
||||||
},
|
},
|
||||||
@@ -40,7 +49,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Weekend Outlook",
|
Name: "Weekend Outlook",
|
||||||
PromptID: "weather.weekend_outlook",
|
PromptID: "weather.weekend_outlook",
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
DefaultOutputName: "weekend.md",
|
ArtifactGroup: "weekend",
|
||||||
|
BatchOutputName: "weekend.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Weekend},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveWeekend,
|
resolve: resolveWeekend,
|
||||||
},
|
},
|
||||||
@@ -49,7 +61,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Storm Report",
|
Name: "Storm Report",
|
||||||
PromptID: "weather.storm_report",
|
PromptID: "weather.storm_report",
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
DefaultOutputName: "storm.md",
|
ArtifactGroup: "storm",
|
||||||
|
BatchOutputName: "storm.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Storm},
|
||||||
resolve: resolveStorm,
|
resolve: resolveStorm,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
|||||||
if metadata.RunID == "" {
|
if metadata.RunID == "" {
|
||||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
||||||
}
|
}
|
||||||
group, err := reportGroup(resolved.Definition.ID)
|
group := resolved.Definition.ArtifactGroup
|
||||||
if err != nil {
|
if group == "" {
|
||||||
return ArtifactPaths{}, err
|
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
filenameBase := metadata.RunID
|
filenameBase := metadata.RunID
|
||||||
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.Briefing, nil
|
return paths.Briefing, nil
|
||||||
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
|
|||||||
if err := promptinput.Validate(pkg); err != nil {
|
if err := promptinput.Validate(pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.DataPackage, nil
|
return paths.DataPackage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
|
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
|
||||||
if result == nil {
|
|
||||||
return "", fmt.Errorf("render result is required")
|
|
||||||
}
|
|
||||||
paths, err := s.Paths(resolved)
|
paths, err := s.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.Preflight, nil
|
return paths.Preflight, nil
|
||||||
@@ -157,27 +154,22 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
|||||||
if metadata.PreflightPath == "" {
|
if metadata.PreflightPath == "" {
|
||||||
return "", fmt.Errorf("metadata preflight path is required")
|
return "", fmt.Errorf("metadata preflight path is required")
|
||||||
}
|
}
|
||||||
path := metadataPathFromStored(metadata)
|
if metadata.MetadataPath == "" {
|
||||||
if path == "" {
|
return "", fmt.Errorf("metadata path is required")
|
||||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(path, metadata); err != nil {
|
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return path, nil
|
return metadata.MetadataPath, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
|
||||||
return s.FindPriorSnapshot(ctx, resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
group, err := reportGroup(resolved.Definition.ID)
|
group := resolved.Definition.ArtifactGroup
|
||||||
if err != nil {
|
if group == "" {
|
||||||
return nil, err
|
return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
dirs, err := s.metadataDirectories(resolved, group)
|
dirs, err := s.metadataDirectories(resolved, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,7 +197,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
if metadata.RunID == resolved.Metadata().RunID {
|
if metadata.RunID == resolved.Metadata().RunID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !comparablePeriod(metadata, resolved) {
|
if !comparablePeriod(metadata, resolved) {
|
||||||
@@ -343,19 +335,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
|
|||||||
return dirs, nil
|
return dirs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
|
|
||||||
switch group {
|
|
||||||
case "daily":
|
|
||||||
return prior == report.DailyToday || prior == report.DailyTomorrow
|
|
||||||
case "three-day":
|
|
||||||
return prior == report.ThreeDay && current == report.ThreeDay
|
|
||||||
case "weekend":
|
|
||||||
return prior == report.Weekend && current == report.Weekend
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FilesystemStore) join(parts ...string) string {
|
func (s *FilesystemStore) join(parts ...string) string {
|
||||||
all := append([]string{s.root}, parts...)
|
all := append([]string{s.root}, parts...)
|
||||||
return filepath.Join(all...)
|
return filepath.Join(all...)
|
||||||
@@ -375,48 +354,6 @@ func validateRelativeDir(name string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportGroup(id report.ID) (string, error) {
|
|
||||||
switch id {
|
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
|
||||||
return "daily", nil
|
|
||||||
case report.ThreeDay:
|
|
||||||
return "three-day", nil
|
|
||||||
case report.Weekend:
|
|
||||||
return "weekend", nil
|
|
||||||
case report.Storm:
|
|
||||||
return "storm", nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown report %q", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSONAtomic(path string, value any) error {
|
|
||||||
data, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal %q: %w", path, err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary file for %q: %w", path, err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return fmt.Errorf("write temporary file for %q: %w", path, err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temporary file for %q: %w", path, err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("save %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readJSON(path string, target any) error {
|
func readJSON(path string, target any) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -428,14 +365,6 @@ func readJSON(path string, target any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadataPathFromStored(metadata Metadata) string {
|
|
||||||
if metadata.BriefingPath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
filename := metadata.RunID + ".metadata.json"
|
|
||||||
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveDataPackage() error = %v", err)
|
t.Fatalf("SaveDataPackage() error = %v", err)
|
||||||
}
|
}
|
||||||
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
|
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SavePreflight() error = %v", err)
|
t.Fatalf("SavePreflight() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
||||||
t.Fatalf("write rendered report: %v", err)
|
t.Fatalf("write rendered report: %v", err)
|
||||||
}
|
}
|
||||||
|
var preflight PreflightArtifact
|
||||||
|
preflightData, err := os.ReadFile(preflightPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read preflight: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(preflightData, &preflight); err != nil {
|
||||||
|
t.Fatalf("decode preflight: %v", err)
|
||||||
|
}
|
||||||
|
if preflight.Stdout != `{"ok":true}` {
|
||||||
|
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
|
||||||
|
}
|
||||||
paths, err := store.Paths(resolved)
|
paths, err := store.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
@@ -112,9 +122,49 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if decoded.RenderedReportPath != renderedReportPath {
|
if decoded.RenderedReportPath != renderedReportPath {
|
||||||
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
||||||
}
|
}
|
||||||
|
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||||
|
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorDailySnapshot(t *testing.T) {
|
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
briefingPackage := stateBriefingPackage(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: paths.Metadata,
|
||||||
|
DataPackage: paths.DataPackage,
|
||||||
|
Preflight: paths.Preflight,
|
||||||
|
RenderedReport: paths.RenderedReport,
|
||||||
|
})
|
||||||
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
if metadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
|
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPriorSnapshot(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
@@ -138,12 +188,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
|||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
if prior == nil {
|
if prior == nil {
|
||||||
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
|
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
|
||||||
}
|
}
|
||||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||||
@@ -153,7 +203,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||||
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
@@ -177,12 +227,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
|||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
|
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
if prior != nil {
|
if prior != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
|
t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
|||||||
GeneratedAt: resolved.GeneratedAt,
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: resolved.Timezone,
|
Timezone: resolved.Timezone,
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: resolved.Timezone,
|
||||||
|
},
|
||||||
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
},
|
},
|
||||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
|||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
|
MetadataPath string `json:"-"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
return Metadata{
|
return Metadata{
|
||||||
SchemaVersion: MetadataSchemaVersion,
|
SchemaVersion: MetadataSchemaVersion,
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
|
MetadataPath: paths.Metadata,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingPackage.Metadata.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: metadata.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
Timezone: metadata.Timezone,
|
Timezone: metadata.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||||
Sources: briefingPackage.Metadata.Sources,
|
Sources: briefingPackage.Metadata.Sources,
|
||||||
@@ -51,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
RenderedReportPath: paths.RenderedReport,
|
RenderedReportPath: paths.RenderedReport,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||||
|
if location == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *location
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package state
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -14,11 +13,10 @@ type Store interface {
|
|||||||
Paths(report.Resolved) (ArtifactPaths, error)
|
Paths(report.Resolved) (ArtifactPaths, error)
|
||||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
||||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||||
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
|
||||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
|
|||||||
Metadata Metadata
|
Metadata Metadata
|
||||||
BriefingPath string
|
BriefingPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreflightArtifact struct {
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||||
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user