21 Commits

Author SHA1 Message Date
63749a9572 Implement support for NWS weather stories 2026-05-30 07:47:49 -05:00
9ff90d33fc Add Woodpecker CI support 2026-05-30 07:47:13 -05:00
942e8ff591 Added a field with the current local date to the data package 2026-05-29 23:29:04 -05:00
0b050256f9 Add a current conditions block to the briefing/data-package 2026-05-29 20:04:27 -05:00
8a762bf34f Added a location block to the data package 2026-05-29 19:57:08 -05:00
42defcf4b9 Implemented the AFD Short Term / Long Term coverage 2026-05-29 19:42:07 -05:00
3e93a97d10 Improve handling of alerts when no active alerts are present 2026-05-29 19:34:23 -05:00
26e6f33cde Update default timezone, dayparts, and related tests 2026-05-29 19:22:02 -05:00
1bc0739d31 Removed detection for "thunder" as a special weather indicator 2026-05-29 19:15:32 -05:00
8476dab844 Consolidate future roadmap items 2026-05-29 18:27:34 -05:00
745992886c Remove the redundant DefaultOutputName report definition field 2026-05-29 18:25:38 -05:00
8089f62806 Refresh cleanup-related documentation 2026-05-29 20:49:19 +00:00
a34aec1dd2 Align cleanup documentation 2026-05-29 20:46:05 +00:00
448bd1e510 Remove legacy daily report wrappers 2026-05-29 20:44:25 +00:00
4e23e1e11f Deduplicate adapter execution and storm parsing 2026-05-29 20:41:47 +00:00
5d3b850e46 Simplify inspect command handling 2026-05-29 20:39:17 +00:00
4f45dee332 Remove unused report output config 2026-05-29 20:36:29 +00:00
1355605e70 Centralize atomic artifact writes 2026-05-29 20:33:35 +00:00
7dc2ac9253 Centralize report path policy 2026-05-29 20:28:06 +00:00
6915bf1ba2 Removed the documentation roadmap, and added a new cleanup roadmap based upon the code quality audit 2026-05-29 15:23:27 -05:00
ac6ede8f9c Audit code quality and deduplication opportunities 2026-05-29 15:17:24 -05:00
58 changed files with 1806 additions and 1621 deletions

3
.gitignore vendored
View File

@@ -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
View 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

View File

@@ -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`

View File

@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
} }
``` ```
`data: null` is treated as a missing source. Missing optional sources follow the 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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."
} }
} }
} }

View 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"
}
}

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")
} }

View File

@@ -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: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
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"},
} }
} }

View File

@@ -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:

View File

@@ -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 != "" {

View File

@@ -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")
} }

View File

@@ -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
} }

View File

@@ -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)
}
} }

View File

@@ -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
} }

View File

@@ -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)
}
} }

View File

@@ -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
} }

View File

@@ -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)
} }
} }

View File

@@ -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)
} }
} }

View File

@@ -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: &currentTemp}, Temperature: forecast.Range{Max: &currentTemp},
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)
} }

View File

@@ -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("", " ")

View File

@@ -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")

View File

@@ -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"`

View File

@@ -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)
}
} }

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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")
} }

View 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)
}

View 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)
}
}

View File

@@ -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"`
} }

View File

@@ -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,
} }
} }

View File

@@ -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))

View File

@@ -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": [
{ {

View File

@@ -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
} }

View File

@@ -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",
}, },

View File

@@ -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

View File

@@ -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})

View File

@@ -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,
}, },
} }

View File

@@ -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")
} }

View File

@@ -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"},
} }

View File

@@ -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
}

View File

@@ -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"`
}