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