42 Commits

Author SHA1 Message Date
2dbba36bf0 Document Weatherreporter v0.9.0 2026-07-31 19:22:43 +00:00
f302581722 Document Weatherreporter release procedure 2026-07-31 19:17:24 +00:00
cf82633ab7 Harden release publication plumbing 2026-07-31 19:13:45 +00:00
8d8cdbf3c5 Finalize Promptkit migration documentation 2026-07-31 17:27:04 +00:00
a206979307 Restore CLI and inspection coverage 2026-07-31 17:22:19 +00:00
a6515c0e56 Restore batch workflow coverage 2026-07-31 17:15:01 +00:00
41df5058ba Simplify prompt report orchestration 2026-07-31 17:08:33 +00:00
e1bc174ea9 Restore single-report workflow coverage 2026-07-31 17:02:31 +00:00
34c395d7e5 Track completed execution artifact paths 2026-07-31 16:51:12 +00:00
870b54a4a0 Harden durable prompt state contracts 2026-07-31 16:45:26 +00:00
25782447eb Correct artifact path bookkeeping 2026-07-31 16:37:00 +00:00
b96f40e5ca Document Promptkit report generation 2026-07-31 05:03:02 +00:00
2c68d0a85f Complete Promptkit batch execution cutover 2026-07-31 04:56:48 +00:00
a6d11c01e8 Add Promptkit debug capture for generated reports 2026-07-31 04:48:16 +00:00
06b26d5e88 Use Promptkit for single report generation 2026-07-31 04:41:02 +00:00
9a17a8de93 Add Promptkit configuration and inspection seams 2026-07-31 04:27:36 +00:00
6064af2295 Add secure prompt debug storage 2026-07-31 04:20:30 +00:00
a52a6ed22a Add durable prompt execution state records 2026-07-31 04:13:00 +00:00
b0b703eab4 Add Promptkit execution adapter 2026-07-31 04:04:46 +00:00
e4e824ed41 Define prompt execution contract 2026-07-31 03:58:18 +00:00
d5fcbfd20c Prepare reports for Promptkit migration 2026-07-31 03:53:47 +00:00
2e0fb65a8b Add scriptorium prompts and schemas to the temporary roadmap 2026-07-30 21:12:52 -05:00
5e96790d85 Correct documentation refresh findings 2026-07-31 01:59:13 +00:00
35f4f82e94 Clarify future roadmap statuses 2026-07-31 01:39:33 +00:00
b605596bcb Refresh report and template internals documentation 2026-07-31 01:36:28 +00:00
9303502b32 Refresh deterministic domain documentation 2026-07-31 01:29:48 +00:00
f9eef80233 Refresh internal state and adapter documentation 2026-07-31 01:26:20 +00:00
c6f8570474 Refresh CLI collection and app internals 2026-07-31 01:21:23 +00:00
1130d807dc Refresh Distributor integration guides 2026-07-31 01:17:37 +00:00
ff2e664c62 Refresh Scriptorium integration guide 2026-07-31 01:14:04 +00:00
2f3558cf33 Refresh Weather API integration guide 2026-07-31 01:11:22 +00:00
154d31c3e8 Refresh report template guide 2026-07-31 01:07:32 +00:00
6b1ff862f3 Refresh troubleshooting guidance 2026-07-31 01:03:58 +00:00
0c27fab384 Refresh README and operations guide 2026-07-31 01:00:21 +00:00
ad3b788f8c Refresh CLI and configuration reference 2026-07-31 00:57:46 +00:00
82acb8dc1a Refresh documentation foundation and repair links 2026-07-31 00:50:48 +00:00
3aaddda676 Add feature roadmap for adoption of the promptkit LLM adapter library 2026-07-30 17:00:32 +00:00
7f989839cd Implement default precision=0 for upstream weatherapi endpoints 2026-07-02 11:39:17 -05:00
27506168f8 Implement warmup and fetch retry in the weatherapi adapter 2026-07-02 11:33:16 -05:00
dc11e08e22 Update the Alert Digest partial template to be more concise 2026-07-02 11:05:31 -05:00
fdddb5f08d Add background definitions for SPC convective outlook risk products 2026-06-21 14:30:08 -05:00
f78186b020 Remove redundant alert text from the data package 2026-06-21 08:38:11 -05:00
151 changed files with 12031 additions and 15325 deletions

View File

@@ -2,8 +2,50 @@ when:
- event: tag
steps:
- name: validate-release
image: golang:1.26.5
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
release_note="docs/releases/$version.md"
if ! printf '%s\n' "$version" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release tag: $version" >&2
exit 1
fi
test -s "$release_note"
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
git diff --check
- name: build-release-assets
image: golang:1.25
image: golang:1.26.5
depends_on:
- validate-release
commands:
- |
set -eu
@@ -33,8 +75,11 @@ steps:
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
host_binary="$dist/weatherreporter-$version-$(go env GOOS)-$(go env GOARCH)"
test "$("$host_binary" --version)" = "weatherreporter $version"
- name: publish-release
image: woodpeckerci/plugin-release
image: woodpeckerci/plugin-release:0.3.1
depends_on:
- build-release-assets
settings:
@@ -42,6 +87,8 @@ steps:
from_secret: GITEA_RELEASE_TOKEN
files:
- dist/weatherreporter-*
title: Weatherreporter ${CI_COMMIT_TAG}
note: docs/releases/${CI_COMMIT_TAG}.md
checksum: sha256
checksum-file: SHA256SUMS
checksum-flatten: true

View File

@@ -1,4 +1 @@
Please carefully review the documents in `docs/policy` before making any changes to this repository.
- `architecture.md` provides the canonical high-level architecture policy for this repository.
- `development.md` provides more granular development policy for this repository.
- `documentation.md` provides the canonical documentation policy for this repository.
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.

View File

@@ -1,10 +1,10 @@
# weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. It builds JSON module snapshots, passes
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
under a local workspace. It can also upload successfully generated managed
Markdown reports to a configured `distributor` HTTP upload endpoint.
Weatherreporter is a Go CLI that turns normalized weather data into managed,
human-facing Markdown reports.
It provides repeatable reports with inspectable local artifacts, so operators
can review what was collected and generated for every run.
## Quickstart
@@ -12,11 +12,14 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
weatherreporter generate today --out ./today.md
```
Configure a Weather API endpoint first; see the
[configuration reference](docs/config.md).
## Documentation
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Development guide](docs/development.md)
- [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md)

View File

@@ -1,7 +1,7 @@
# Weatherreporter CLI
`weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects stored artifacts.
`weatherreporter` generates weather reports, runs report batches, and inspects
artifacts already stored in its workspace.
## Shortest Useful Command
@@ -9,26 +9,21 @@ batches, and inspects stored artifacts.
weatherreporter generate today --out ./today.md
```
This loads configuration, collects weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, validates generated text, renders the embedded Today
template, and writes an extra Markdown copy to `./today.md`. If distributor
notification is enabled in configuration, the command also uploads the managed
Markdown report after final metadata is saved.
The command uses the configured Weather API and writes an extra Markdown copy
at `./today.md`. See the [configuration reference](config.md) to supply the
required Weather API endpoint.
## Commands
## Commands And Usage
```text
weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter --version
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
@@ -37,57 +32,41 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
```
Implemented `generate` commands emit a compact JSON summary to stdout on
success. The summary includes command identity, report identity, RunID, status,
valid period, and managed artifact paths. They also write a JSON module
snapshot, YAML data package, preflight artifact, managed Markdown report, and
metadata under the configured workspace. `--out` writes an extra Markdown copy
for the operator; distributor notification uses the managed report path, not
the extra copy. `generate daily`,
`generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is collected. `generate hourly`
covers the next six hours in the effective report timezone and does not accept
date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`.
`weatherreporter --version` prints the version embedded in the executable.
Tagged release binaries report their semantic version tag; ordinary local
builds report `development`.
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
for each later future local civil day with complete hourly forecast coverage.
`run evening` generates Tomorrow Report and the same eligible future Daily
reports. Future Daily expansion starts with the day after tomorrow and skips
days that do not have every hourly forecast period for the local civil day.
Batch commands collect weather data once before planning; a collection failure
stops the batch before any report is generated. Batch runs continue independent
reports after a later report failure, print a JSON summary to stdout, write
compact status lines to stderr, and return nonzero when any report failed.
`--out-dir` writes extra Markdown copies for the operator; distributor
notification uses managed report paths, not the extra copies. Today and
Tomorrow use their report default copy names, and dynamic Daily copies use
`daily-YYYY-MM-DD.md`. When distributor and batch notification are enabled, a
fully successful batch uploads one distributor bundle after report generation
finishes. The JSON summary exposes that upload as a top-level `notification`
object, and stderr includes one `batchNotification` status line. If any planned
report fails, the batch notification is skipped for the whole batch.
| Command | Contract |
| --- | --- |
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. |
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. |
| `generate tomorrow` | Uses the next local civil day and accepts the common generate flags. |
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--hours`, or `--duration`. |
| `run morning` and `run evening` | Run their defined report batches. `--out-dir` writes extra Markdown copies; `--out` is not accepted. |
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
not included in `run morning` or `run evening`.
`generate` accepts the four report command names shown above. `run` accepts
only `morning` and `evening`. Batch membership, workspace artifacts, and
notification sequencing are described in the [operations guide](operations.md).
`inspect` commands read existing workspace artifacts and emit the requested
JSON data to stdout. They do not collect weather data or invoke `scriptorium`.
Inspection commands do not accept `--quiet`.
## Output, Errors, And Quiet Mode
## Output
Action commands (`generate` and `run`) write a JSON summary to stdout unless
`--quiet` is set. `run` also writes compact per-report and batch status lines
to stderr. A pre-run error, such as an invalid flag, missing required argument,
or configuration-load failure, produces no partial JSON summary. When an action
fails after it has produced a result, its summary has `"status": "failed"` and
an `error` field.
Action commands, meaning `generate` and `run`, emit JSON summaries to stdout by
default. Pre-run errors, such as invalid flags, missing required arguments, or
configuration load failures, return an error without emitting partial JSON.
`--quiet` suppresses successful action-command stdout and routine stderr. It
does not hide returned errors. Inspection commands are data-output commands;
they always write the requested JSON to stdout and are not quietable.
`--quiet` is supported by action commands only. It suppresses action summaries
and routine batch status output; it does not suppress command errors.
Generate summaries have this shape:
Inspection commands always write their requested JSON value to stdout and do
not accept `--quiet`.
### Generate Summary
A generate summary always identifies the command, report, run, generation
time, valid period, and status:
```json
{
@@ -101,102 +80,59 @@ Generate summaries have this shape:
"validPeriod": {
"start": "2026-05-29T00:00:00-05:00",
"end": "2026-05-30T00:00:00-05:00"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json",
"generatedTextRawPath": "workspace/snapshots/today/2026-05-29/generated_text_raw.20260529T120000.000000000Z_today.json",
"generatedTextResultPath": "workspace/snapshots/today/2026-05-29/generated_text_result.20260529T120000.000000000Z_today.json",
"generatedTextPath": "workspace/snapshots/today/2026-05-29/generated_text.20260529T120000.000000000Z_today.json",
"renderContextPath": "workspace/snapshots/today/2026-05-29/render_context.20260529T120000.000000000Z_today.json"
}
}
```
Markdown-path reports omit the generated-text fields. If distributor
notification is attempted, summaries include `notificationPath`; successful
notification also includes a compact `notification` object. If notification
fails after report artifacts exist, the summary has `"status": "failed"` and an
`error` string while retaining inspectable artifact paths.
When available, the summary also includes `reportPath`, `metadataPath`,
`dataPackagePath`, `preparationPath`, `executionPath`, `generatedTextRawPath`,
`generatedTextPath`, `renderContextPath`, and `llmDebugPath`. `outputPath` is included only
when `--out` wrote an extra copy. Distributor notification, when attempted,
adds `notificationPath` and may add a compact `notification` object.
Run summaries have this shape:
### Run Summary And Stderr
```json
{
"command": "run",
"batch": "morning",
"status": "succeeded",
"startedAt": "2026-05-29T12:00:00Z",
"finishedAt": "2026-05-29T12:01:00Z",
"total": 1,
"succeeded": 1,
"failed": 0,
"reports": [
{
"reportId": "today",
"reportName": "Today Report",
"promptId": "weather.today_generated_text",
"runId": "20260529T120000.000000000Z_today",
"status": "succeeded",
"generatedAt": "2026-05-29T12:00:00Z",
"validPeriod": {
"start": "2026-05-29T00:00:00-05:00",
"end": "2026-05-30T00:00:00-05:00"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json"
}
]
}
```
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
`total`, `succeeded`, `failed`, and a `reports` array. It may also contain a
top-level `notification` object and `error`. Batch status is `failed` if any
report or the batch notification fails.
`run` status is `failed` when any report failed or the top-level batch
notification failed. Batch stderr uses compact status lines, for example:
Without `--quiet`, batch status lines use this form:
```text
report=today status=succeeded output="reports/today.md"
batch=morning total=2 succeeded=2 failed=0
```
## Flags
## Flag Reference
- `-h`, `--help`: show help.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--quiet`: suppress successful stdout and routine stderr for `generate` and `run`.
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
| Flag | Accepted by | Meaning |
| --- | --- | --- |
| `-h`, `--help` | top level | Show help. |
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
| `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` for this command. |
| `--out PATH` | every `generate` command | Write an extra Markdown report copy. |
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics outside the managed workspace. The path must be absolute. |
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
| `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
| `--limit N` | `inspect reports` | Maximum runs to list. Defaults to `20`; `0` means no limit. |
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
Distributor notification is configured through `notify.distributor`; there are
no Distributor-specific CLI flags. See the [configuration reference](config.md).
Distributor notification is configured only through `notify.distributor`; there
are no distributor-specific CLI flags.
## Common Workflows
## Invocation Examples
```sh
weatherreporter generate today --out ./today.md
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports
weatherreporter generate today --quiet
weatherreporter run morning --quiet
weatherreporter generate today --date 2026-05-29 --out ./today.md
weatherreporter generate hourly --out ./hourly.md
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
```
## Inspection
## Inspection Commands
```sh
weatherreporter inspect reports --limit 10
@@ -205,12 +141,17 @@ weatherreporter inspect modules 20260529T100000.000000000Z_today
weatherreporter inspect data-package 20260529T100000.000000000Z_today
weatherreporter inspect prior 20260529T100000.000000000Z_today
weatherreporter inspect sources 20260529T100000.000000000Z_today
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
```
`inspect reports` lists recent generated runs with artifact paths and source
warning counts. The other inspect commands require a RunID. `inspect modules`
returns the persisted ordered module snapshot for a run. `inspect prior`
returns the prior comparable snapshot metadata selected from stored metadata, or
`null` when none exists. `inspect sources` shows source provenance and source
warnings without dumping full weather payloads.
| Command | JSON returned |
| --- | --- |
| `inspect reports` | Recent generated runs, including artifact paths and source-warning counts. |
| `inspect metadata RUN_ID` | Persisted metadata for the run. |
| `inspect modules RUN_ID` | The run's persisted ordered module snapshot. |
| `inspect data-package RUN_ID` | The run's persisted prompt data package. |
| `inspect prior RUN_ID` | Prior comparable snapshot metadata, or `null` when none exists. |
| `inspect sources RUN_ID` | Source provenance and source warnings without full weather payloads. |
Inspection is read-only: it does not collect weather data or invoke Promptkit.
See the [operations guide](operations.md) for artifact lifecycle
and recovery.

View File

@@ -1,319 +1,209 @@
# Weatherreporter Configuration
Configuration is YAML. By default, `weatherreporter` reads:
Weatherreporter reads YAML configuration. The default path is:
```text
/usr/local/etc/weatherreporter/config.yml
```
Use `--config PATH` to load a different file. If the default file is absent,
built-in defaults are used. If `--config PATH` points to a missing file, loading
fails.
If the default file is absent, Weatherreporter uses built-in defaults. An
explicit `--config PATH` must exist. Values are applied in this order:
Precedence is:
1. built-in defaults;
2. the configuration file, when present; and
3. the `--units` and `--tz` command-line overrides.
1. CLI flags
2. configuration file
3. built-in defaults
Environment variables do not override configuration fields. Output flags write
extra report copies for a command and do not change configuration.
The CLI configuration overrides are `--units` and `--tz`. Output flags control
report copies for the current command but do not change configuration files.
Environment variables do not override configuration fields.
## Maintained Examples
## Minimal Config
- [minimal-config.yml](../examples/minimal-config.yml) is the smallest useful
collection and generation configuration.
- [config.yml](../examples/config.yml) is a representative production-oriented
configuration using synthetic endpoints and no credentials.
See [examples/minimal-config.yml](../examples/minimal-config.yml).
Both files are loaded by the configuration test suite.
## Minimal Configuration
```yaml
weather_api:
base_url: https://weather.api.example.com/
```
`weather_api.base_url` is required for commands that collect weather data.
Other fields fall back to defaults.
## Production-Oriented Config
See [examples/config.yml](../examples/config.yml). The example is loaded by the
config test suite.
`weather_api.base_url` is required for workflows that collect weather data.
All omitted fields use their built-in defaults.
## Field Reference
### `weather_api`
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
- `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`.
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
- `format`: Weather API response format. Must be `json`. Default: `json`.
| Field | Default | Rules |
| --- | --- | --- |
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
| `timeout` | `10s` | Must be greater than zero. |
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
| `timezone` | `America/Chicago` | Required report and Weather API timezone; `--tz` overrides it for one command. |
| `format` | `json` | Required and must be `json`. |
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`.
### `location`
`location` is descriptive prompt context included in module metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations.
`location` supplies descriptive prompt context; it does not choose a Weather
API endpoint or configure multiple forecast locations.
- `id`: short local identifier. Default: `home`.
- `name`: human-readable location name. Default: `Brentwood`.
- `region`: broader forecast area context. Default: `St. Louis Metro`.
| Field | Default |
| --- | --- |
| `id` | `home` |
| `name` | `Brentwood` |
| `region` | `St. Louis Metro` |
The prompt-facing location object also includes `timezone`, derived from the
effective `weather_api.timezone` after CLI overrides such as `--tz`.
The prompt-facing location timezone is derived from the effective
`weather_api.timezone` after command-line overrides.
### `secrets`
- `directory`: optional directory of file-backed environment secrets. Default:
empty, which disables secret loading.
`secrets.directory` defaults to empty, which disables secret loading. When it
is set, every regular file directly in that directory is loaded after the file
and command-line overrides. A file basename must match
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
file contents replace any existing value. One trailing LF or CRLF is removed.
When configured, each regular file directly under `secrets.directory` is loaded
after config file parsing and CLI overrides. The file basename must be a valid
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
become the environment variable value and overwrite any existing value. One
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
missing directories, and unreadable files fail config loading.
Missing directories, unreadable files, subdirectories, symlinks, non-regular
files, and invalid names fail configuration loading. Put only secret values in
this directory, never in the YAML file.
### `notify`
### `notify.distributor`
`notify.distributor` controls distributor uploads after successful report
rendering. It is disabled by default and does not add CLI flags. When enabled,
`generate <report>` uploads one distributor bundle for the generated report
after final metadata is saved. `run morning` and `run evening` use
`notify.distributor.batch`: when batch notification is enabled and every
planned report succeeds, weatherreporter uploads one distributor bundle that
contains all managed Markdown reports from that batch.
Distributor notification is disabled by default. Its fields are:
- `enabled`: whether distributor notification config is active. Default:
`false`.
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
Default: `https://distributor.example.com`.
- `token_env`: environment variable name that will contain the distributor
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
- `timeout`: distributor operation timeout. Must be greater than zero when
enabled. Default: `30s`.
- `failure_policy`: must be `error` when enabled. Default: `error`.
- `pipeline_id_template`: template for single-report distributor pipeline IDs.
Required when enabled. Default: empty.
- `bundle_id_template`: template for single-report distributor bundle IDs.
Default: `weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for single-report distributor
idempotency keys. Default: `{bundle_id}.{run_id}`.
- `batch.enabled`: whether batch distributor notification config is active
when distributor notification is enabled. Default: `true`.
- `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
Required when distributor notification and batch notification are enabled.
Default: `weatherreporter`.
- `batch.bundle_id_template`: template for batch distributor bundle IDs.
Required when distributor notification and batch notification are enabled.
Default: `weatherreporter.{location_id}.{batch}`.
- `batch.idempotency_key_template`: template for batch distributor idempotency
keys. Required when distributor notification and batch notification are
enabled. Default: `{bundle_id}.{batch_run_id}`.
| Field | Default | Rules when notification is enabled |
| --- | --- | --- |
| `enabled` | `false` | Activates Distributor notification validation. |
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
| `timeout` | `30s` | Must be greater than zero. |
| `failure_policy` | `error` | Must be `error`. |
| `pipeline_id_template` | empty | Required single-report pipeline ID template. |
| `bundle_id_template` | `weatherreporter.{location_id}.{report_id}` | Required single-report bundle ID template. |
| `idempotency_key_template` | `{bundle_id}.{run_id}` | Required single-report idempotency-key template. |
| `batch.enabled` | `true` | Activates batch notification validation when Distributor notification is enabled. |
| `batch.pipeline_id_template` | `weatherreporter` | Required when batch notification is enabled. |
| `batch.bundle_id_template` | `weatherreporter.{location_id}.{batch}` | Required when batch notification is enabled. |
| `batch.idempotency_key_template` | `{bundle_id}.{batch_run_id}` | Required when batch notification is enabled. |
Single-report templates support `location_id`, `report_id`, `run_id`,
The upload token is read from the environment variable named by `token_env`.
Use `secrets.directory` when a file-backed secret is appropriate.
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
and `storm_id`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
`storm_id` is derived from the storm report valid period as
`{valid_start_stamp}-{valid_end_stamp}`; it renders empty for non-storm
reports. `pipeline_id_template` and `idempotency_key_template` may also use
`bundle_id`.
Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
`YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
effective report timezone.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The rendered bundle ID is the stable logical source identity for the
report stream. The rendered idempotency key is the per-run retry identity.
Batch bundle and pipeline templates accept `location_id`, `batch`,
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
also use `bundle_id`. `batch_started_date` is the batch start date in the
effective report timezone.
Batch templates support `location_id`, `batch`, `batch_run_id`, and
`batch_started_date`. Batch idempotency templates may also use `bundle_id`.
`batch_started_date` is the batch start date in the effective report timezone.
Batch bundle IDs identify a logical batch stream; batch idempotency keys
identify a specific retryable batch attempt.
`reports.<report>.distributor.path_templates` overrides the default ordered
Distributor paths for that report. Each rendered path must be a unique relative
path with `/` separators. Backslashes, empty segments, `.` and `..` segments,
`manifest.json`, and the reserved Distributor sidecar basename are rejected.
The default paths are:
Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or the reserved distributor sidecar basename, formed from a leading dot plus
`distributor.json`. In a batch upload, uniqueness is checked across every
rendered bundle path for every included report before distributor is called.
Managed Markdown report paths are the only upload source files; copies written
with `--out` or `--out-dir` are never uploaded.
| Report | Paths |
| --- | --- |
| `hourly` | `hourly/index.md` |
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md` |
| `today` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md` |
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md` |
Distributor bundle paths are report-specific. Weatherreporter uses
`reports.<report>.distributor.path_templates` when that override is configured;
otherwise it uses the report definition defaults:
- `hourly`: `hourly/index.md`
- `daily`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`
- `today`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md`
- `tomorrow`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md`
- `three_day`: `three-day/{valid_start_date}/{run_id}.md`, `three-day/{valid_start_date}/index.md`
- `weekend`: `weekend/{valid_start_date}/{run_id}.md`, `weekend/{valid_start_date}/index.md`
- `storm`: `storm/{storm_id}/{run_id}.md`, `storm/{storm_id}/index.md`
The upload token is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. Config files should
name the variable only; they should not contain the token value.
See the [operations guide](operations.md) for notification timing, uploaded
artifact selection, and failure handling.
### `missing_source`
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
- `sources`: optional map of source-specific overrides, using the same policy values.
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
`none`. `missing_source.sources` optionally overrides that policy by source.
Hourly forecast data is required for generated reports. Supported optional
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
`weather_story`, and `spc_convective_outlooks`.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `promptkit`
### `scriptorium`
Promptkit configuration selects the executor and prompt/profile checks for
every `generate` and `run` command. A top-level `scriptorium:` configuration
key is rejected with a migration error; it is not translated or ignored.
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
- `config_path`: optional Scriptorium config path passed to the adapter.
- `profile`: optional Scriptorium profile passed to the adapter.
- `timeout`: subprocess timeout. Default: `2m`.
- `extra_args`: optional additional arguments passed to Scriptorium commands.
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
individual `generate` or `run` command when explicitly needed.
| Field | Default | Rules |
| --- | --- | --- |
| `profile` | empty | Optional explicit execution profile. Otherwise the prompt's declared default is used. |
| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. |
| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. |
| `timeout` | `2m` | Must be greater than zero. |
| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. |
| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. |
### `workspace`
- `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
| Field | Default |
| --- | --- |
| `root` | `workspace` |
| `snapshots_dir` | `snapshots` |
| `reports_dir` | `reports` |
| `data_packages_dir` | `data-packages` |
| `preflight_dir` | `preflight` |
| `notifications_dir` | `notifications` |
Workspace subdirectories must be relative paths that stay inside
`workspace.root`. Managed artifact paths below those directories are grouped by
artifact group and valid-period start date; the path template is not
configurable.
`workspace.root` is required. Each workspace subdirectory must be a relative
path that stays within the root. See the [operations guide](operations.md) for
the managed workspace layout and lifecycle.
### `dayparts`
`dayparts` is a list of named local-time windows used by forecast derivation.
Each entry has:
- `name`
- `start`
- `end`
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
midday, afternoon, and evening.
`dayparts` is a non-empty list of named local-time windows used in forecast
derivation. Every item needs `name`, `start`, and `end`; start and end use
`HH:MM`. Defaults are `overnight` (`00:00``06:00`), `morning`
(`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon`
(`15:00``17:00`), and `evening` (`17:00``24:00`).
### `recent_change`
- `temperature_degrees`: temperature change threshold. Default: `5`.
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
| Field | Default |
| --- | --- |
| `temperature_degrees` | `5` |
| `precip_probability_points` | `20` |
| `wind_gust_miles_per_hour` | `10` |
| `precip_timing_shift_minutes` | `120` |
Recent Changes are added to prompt input when a prior comparable module
snapshot exists and a threshold is crossed.
These thresholds control when Recent Changes are included in prompt input for a
prior comparable module snapshot.
### `reports`
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
`reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
are also valid, including `three_day_outlook`, `weekend_outlook`, and
`storm_report`. Hyphens and underscores are treated equivalently in report
keys. Retired report keys are not supported.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
and underscores are equivalent.
`reports.today` applies only to the Today Report. `reports.daily` applies only
to the dated Daily Report.
Each report entry can contain:
Each report entry supports:
- `deterministic_modules`: an ordered list of module IDs, or objects with `id`
and optional `options`.
- `distributor.path_templates`: an optional, non-empty ordered list of
Distributor paths for that report.
- `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`.
- `distributor.path_templates`: optional ordered distributor bundle path
templates for this report. If omitted, the report definition defaults are
used. If present, the list must contain at least one template.
Example:
```yaml
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- long_term
- spc_convective_discussion
- daily_planning
- hourly_forecast
today:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- area_forecast_discussion
- spc_convective_discussion
- weather_story
- outdoor_windows
- hourly_forecast
- today_planning
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections. Default report definitions may choose a
smaller report-specific subset, such as daily reports using only `long_term`.
The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented
module IDs fail validation instead of being skipped.
## Secrets
Configuration files should not contain raw secrets. Use `secrets.directory` to
load secret values from files into environment variables for integrations that
read credentials from the environment. Secret file names become environment
variable names, and secret file contents become values. For distributor
notification, this allows a file such as
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
`notify.distributor.token_env`.
## Maintained Examples
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
useful config for generation and fetching.
- [examples/config.yml](../examples/config.yml): production-oriented config
covering maintained fields.
Both example files are loaded by the config test suite.
Unknown reports and modules, duplicate modules, incompatible report-module
combinations, duplicate stanza names, invalid path templates, and invalid
module options fail configuration loading. The accepted module IDs and module
option contracts are documented in the [module contract internals](internal/module.md).

87
docs/development.md Normal file
View File

@@ -0,0 +1,87 @@
# Development
This is the first-read guide for people and coding agents working on
Weatherreporter. It provides a concise repository orientation and routes each
kind of change to its canonical documentation.
Weatherreporter is a Go CLI that collects normalized weather data, derives
deterministic report facts and module snapshots, executes Promptkit for
single-report generated text, renders managed Markdown reports, and can upload completed
reports through Distributor. Start with the [README](../README.md) for product
context and the [architecture policy](policy/architecture.md) for system
boundaries and invariants.
## What To Read
| When working on | Read | Why |
| --- | --- | --- |
| Product behavior or the shortest useful workflow | [README](../README.md), [CLI reference](cli.md), and [operations guide](operations.md) | These own product orientation, invocation, and normal operation. |
| Application shape, package boundaries, dependency direction, safety properties, or architectural invariants | [Architecture policy](policy/architecture.md) and relevant ADRs under `docs/adr/`, when present | Architecture defines the intended system; ADRs preserve significant decision rationale. |
| Any documentation addition, revision, move, or removal | [Documentation policy](policy/documentation.md) | It defines canonical owners, audience boundaries, current-state rules, and document lifecycle. |
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
| Top-level generation, batch, collection, inspection, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, persistence points, failure propagation, and orchestration invariants. |
| Weather API transport, source envelopes, source warnings, or collection | [Weather API integration](integrations/weatherapi.md), [weather-data internals](internal/weather-data.md), and [collection internals](internal/collect.md) | These separate the external contract, normalized source facts, and app-facing collection behavior. |
| Forecast periods, weather derivation, collected facts, or derived facts | [Forecast derivation internals](internal/forecast-derivation.md) and [fact contracts](internal/facts.md) | They own deterministic derivation and the fact boundaries used by reports. |
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
| Recent Changes comparison | [Changes internals](internal/changes.md) and [operations guide](operations.md) | The internal guide owns structured comparison; operations owns user-visible artifact behavior. |
| Prompt execution, profiles, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
| Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. |
| Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
| Release preparation, tagging, publication, or verification | [Release procedure](release.md) | It owns version selection, release-note preparation, candidate validation, tag publication, CI behavior, and post-publication checks. |
| Proposed, deferred, or unimplemented work | Documents under `docs/roadmap/` | Future behavior and implementation status belong only in roadmaps until implemented. |
For an existing subsystem, inspect its focused internal document, package-local
types, and tests before changing behavior. Use the package boundaries already
present before introducing a new package or abstraction.
## Repository Map
| Area | Responsibility |
| --- | --- |
| `cmd/weatherreporter` | Binary entry point. |
| `internal/cli` | Command parsing, flags, help, output, and command wiring. |
| `internal/app` | Generation, batches, collection coordination, notification, and inspection orchestration. |
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
| `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
| `internal/report`, `internal/module`, `internal/briefing`, `internal/changes` | Report registry, module contracts and values, and structured comparison. |
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
| `internal/state`, `internal/fileutil`, `internal/timeutil` | Durable artifacts, atomic file operations, clocks, dates, timezones, and periods. |
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
| `examples` | Maintained copyable configuration. |
The [architecture policy](policy/architecture.md) is authoritative for
normative boundaries. Focused documents under `docs/internal/` own detailed
implemented subsystem behavior.
## Contributor Workflow
1. Read the documents and focused tests identified by the task guide.
2. Use focused package checks while iterating.
3. Run `gofmt -w` on changed Go files.
4. Update the canonical documentation and maintained examples in the same
change when behavior changes.
5. Run repository-wide validation before considering the work complete.
Preserve actionable error context, keep secrets out of logs and fixtures, and
avoid validation that requires live Weather API, Promptkit providers, or Distributor
services. The architecture and testing policies own the detailed rules.
## Baseline Validation
Run:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Use focused package tests during development and add broader or race-enabled
checks when required by the [testing policy](policy/testing.md) and the risks of
the change.

View File

@@ -1,136 +1,70 @@
# Upstream Producer Integration
# Distributor HTTP Upload Contract
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
Weatherreporter integrates with the HTTP upload API provided by
`gitea.maximumdirect.net/eric/distributor v0.5.0`. It submits source bundles to
a configured pipeline and reads the resulting run status. Configuration fields
and notification lifecycle are documented in the [configuration reference](../../config.md)
and [operations guide](../../operations.md).
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
## Upload Admission
## Required Inputs
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
posts a gzip-compressed source bundle to:
The upstream application needs these values from deployment or operator configuration:
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
- upload token: bearer token that authenticates the producer;
- pipeline id: configured `http_upload` pipeline that should process this upload;
- generated files: regular local files to include in the source bundle;
- bundle id: stable identifier for the logical report stream or artifact;
- idempotency key: unique key for one producer run, reused only when retrying that same run.
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
## Recommended Workflow
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
Add the dependency from the upstream application:
```sh
go get gitea.maximumdirect.net/eric/distributor
```text
POST /v1/pipelines/<pipeline_id>/upload
Authorization: Bearer <token>
Content-Type: application/gzip
Idempotency-Key: <key>
```
## Minimal Go Example
The authenticated token must be allowed to use the selected upload pipeline.
A successful response is `202 Accepted` with JSON containing `run_id` and
`status`. Acceptance means Distributor staged and validated the source bundle;
it does not mean downstream destinations have published it.
```go
package reports
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
source-file mapping before calling Distributor. It reads the bearer token from
the configured environment variable and redacts that value from errors. Request
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
import (
"context"
"errors"
"fmt"
"os"
"time"
## Idempotency
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
non-empty ASCII values of at most 128 bytes using letters, digits, `.`, `_`,
`-`, and `:`. Weatherreporter always supplies a rendered key; it does not rely
on the client library's generated-key fallback.
func SubmitReport(reportPath, summaryPath string) error {
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
if endpoint == "" || token == "" {
return fmt.Errorf("distributor endpoint and token are required")
}
Reusing a key for the same normalized source manifest returns the original
accepted run. Reusing it for different content returns `409 Conflict`, which
the adapter exposes as a Weatherreporter idempotency-conflict error. A distinct
report or batch run therefore needs a distinct key; reuse a key only when
retrying that same upload.
pipelineID := "weather-hourly"
reportID := "weather.hourly.brentwood"
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
## Run Status And Retention
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: endpoint,
Token: token,
})
if err != nil {
return err
}
After acceptance, Weatherreporter reads:
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: pipelineID,
ID: reportID,
IdempotencyKey: reportID + "." + runID,
Files: []bundle.BundleFile{
{SourcePath: reportPath, Path: "report.md"},
{SourcePath: summaryPath, Path: "summary.txt"},
},
})
if err != nil {
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
return err
}
fmt.Printf("distributor accepted run %s\n", result.RunID)
return nil
}
```text
GET /runs/<run_id>
Authorization: Bearer <token>
```
## Producer Responsibilities
The status record provides `run_id`, `pipeline_id`, status timestamps, optional
JSON `report`, and an `error` for failures. Statuses are `accepted`, `queued`,
`running`, `succeeded`, and `failed`. A terminal `failed` status makes the
notification fail; the adapter preserves the returned status details for the
application to record.
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Run and idempotency records are in-memory. Completed records expire according
to Distributor's `server.http.retention`, and a Distributor restart removes
retained status and idempotency state. Status polling decisions and persistence
of notification artifacts are internal orchestration behavior; see the
[Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md).
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames such as `manifest.json` and the distributor sidecar basename formed from a leading dot plus `distributor.json`.
## Compatibility Reference
## Idempotency And Status
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
Optional status check:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
## References
In the `distributor` source tree:
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.
The upstream canonical HTTP wire contract is
`docs/integrations/http-upload.md` in the Distributor repository. This page
documents only the portion exercised by Weatherreporter.

View File

@@ -1,91 +1,36 @@
# `pkg/bundle`
# Distributor Source Bundle Mapping
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
Weatherreporter uses the source-bundle format through Distributor's
`pkg/upload.UploadFiles` helper. It does not create bundle directories or call
`pkg/bundle` directly. The helper creates a temporary bundle, writes and
validates `manifest.json`, archives it, and removes the temporary bundle when
the upload call returns.
Import path:
## File Mappings
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
Every mapping pairs a managed Markdown report source with one bundle-relative
path. A single-report notification maps its one managed report to each rendered
path configured for that report. A batch notification combines mappings for
every included managed report and rejects duplicate bundle paths.
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
The report source is never an `--out` copy or an arbitrary workspace scan. The
application selects it and renders notification paths; see the [operations guide](../../operations.md)
for the managed-upload rule and the [Distributor adapter](../../internal/distributor-adapter.md)
for the adapter boundary.
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
Bundle paths must be clean, relative, slash-separated paths. They cannot be
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
`manifest.json` or `.distributor.json` as a basename. The mapped source must be
a regular file. File mapping order is preserved and affects the bundle digest.
## Preferred Complete-Bundle Workflow
The bundle manifest uses schema version `1`, carries the rendered bundle ID and
creation time, and records each mapped file's path, SHA-256 digest, and size.
Destination routing, publication, and Distributor-managed destination state are
not source-bundle fields.
Use `WriteBundle` when producer-generated files live outside the final bundle root.
## Compatibility Reference
```go
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
ID: "weather.hourly.brentwood",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = manifest
```
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
## Existing Bundle Root Workflow
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
```go
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
Root: root,
ID: "weather.hourly.brentwood",
Files: []string{"report.md", "summary.txt"},
})
if err != nil {
return err
}
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
return err
}
if err := bundle.ValidateBundle(root, manifest); err != nil {
return err
}
```
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
## Paths And Ordering
Bundle paths are slash-separated paths relative to the bundle root.
Invalid paths include:
- empty paths;
- absolute paths;
- paths containing backslashes;
- `.` or `..` path segments;
- empty path segments;
- any reserved basename, including `manifest.json` and the distributor sidecar
basename formed from a leading dot plus `distributor.json`.
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
## Validation And Digest Helpers
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
Useful helpers:
- `LoadManifest`: read `manifest.json` from a bundle root.
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
- `ValidateManifest`: validate manifest-only semantics.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
## Boundaries
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.
The upstream canonical file-format contract is
`docs/integrations/source-bundle.md` in the Distributor repository. It defines
the complete manifest and archive format; this page records only the mapping and
path constraints Weatherreporter relies on.

View File

@@ -1,122 +1,51 @@
# `pkg/upload`
# Distributor Upload Client Contract
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
Weatherreporter uses `gitea.maximumdirect.net/eric/distributor/pkg/upload` at
the pinned module version `v0.5.0`. It constructs one client per notification
attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
Import path:
## Client And Upload
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
```
The adapter constructs the client with the configured endpoint, bearer token,
and an HTTP client whose timeout is the configured Distributor timeout. It
passes no custom retry options, so the pinned client's defaults apply: three
attempts, 100 ms base delay, and one-second maximum delay.
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
For each notification, Weatherreporter calls `UploadFiles` with:
`UploadFiles` examples also use:
- the rendered pipeline ID;
- the rendered bundle ID as the source manifest ID;
- the report or batch generation time as `Created`;
- the managed-report-to-bundle-path mappings described in the
[bundle mapping contract](pkg-bundle.md); and
- a rendered idempotency key.
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
It leaves bundle validation enabled. `UploadFiles` creates the temporary source
bundle and sends it as a gzip-compressed tar archive; Weatherreporter does not
call `UploadBundle` or submit prebuilt bundle roots.
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
## Retry, Conflict, And Status
## Client Construction
The pinned upload client retries only `503 Service Unavailable` and retryable
network failures. It does not retry successful `202` responses or other HTTP
errors. Because every Weatherreporter request supplies an idempotency key, a
retry keeps the same upload identity.
```go
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "https://distributor.example.com",
Token: token,
})
if err != nil {
return err
}
```
The client decodes the accepted upload result (`run_id`, `status`) and the run
status record. A `409` response is an upstream idempotency conflict; the
adapter translates it to its own conflict error without exposing the token.
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
The adapter then calls `Status` for the accepted run. A terminal `failed`
status is a notification failure. A status lookup failure or a timeout before a
terminal status remains attached to the otherwise accepted upload as diagnostic
status information. Polling cadence, final failure handling, redaction, and
notification artifact persistence are internal behavior documented in the
[Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md).
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
## Compatibility Reference
## Upload Producer Files
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
```go
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: "weather-hourly",
ID: "weather.hourly.brentwood",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
## Upload An Existing Bundle
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
```go
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "weather-hourly",
Root: "/var/spool/weather/hourly-2026-06-07T15",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
## Result And Status
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
Poll status while the server retains the in-memory run record:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
## Idempotency And Retry
Every upload request includes `Idempotency-Key`.
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
The client retries only safe cases:
- `503 Service Unavailable`;
- temporary network errors;
- ambiguous mid-upload failures.
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
Detect conflicting key reuse with `errors.As`:
```go
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
```
## Boundaries
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.
The upstream package workflow is documented in
`docs/consumers/pkg-upload.md` in the Distributor repository. Weatherreporter
uses only the client construction, `UploadFiles`, retry/conflict behavior, and
`Status` operations described here.

View File

@@ -0,0 +1,22 @@
# Promptkit Integration
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are
`weather.daily_generated_text`, `weather.today_generated_text`,
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version
`1.0.0`. Their prompt assets and generated-text JSON Schemas are embedded by
`internal/promptassets`.
Before collection, Weatherreporter inspects the exact prompt version, requires one required
`data_package` input with content type `application/yaml`, and requires the report's JSON
Schema output contract. It selects `promptkit.profile` when configured, otherwise the
prompt's declared default profile. Profiles that require a direct API key are unsupported; a
profile that reports `APIKeyEnv` requires a nonblank value in that environment variable.
Execution receives the already-persisted YAML package, prepares it once, and returns structured
JSON that Weatherreporter validates before rendering its own Markdown template. Preparation and
execution receipts are project-owned, safe provenance records. Content-rich diagnostics are
opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
Prompt/profile configuration is owned by the [configuration reference](../config.md). Adapter
construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
Durable metadata compatibility is described in [state internals](../internal/state.md).

View File

@@ -1,118 +0,0 @@
# Scriptorium Integration
This document describes the external Scriptorium CLI contract used by
`weatherreporter`.
## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product.
## Commands Used
Render preflight:
```bash
scriptorium render \
--prompt <prompt_id> \
--input data_package=<path> \
--format json
```
Report generation:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <artifact_path>
```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments:
- `--config <path>` from `scriptorium.config_path`
- `--profile <profile>` from `scriptorium.profile`
It appends `scriptorium.extra_args` after the built-in arguments. Extra
arguments are passed directly as argv items.
`scriptorium.binary` selects the executable name or path. If unset inside the
adapter, it falls back to `scriptorium`.
## Execution Behavior
The adapter runs Scriptorium without shell interpolation. Arguments are passed
through `exec.CommandContext`.
`scriptorium.timeout` limits each subprocess call when configured. Context
cancellation or timeout returns an execution error.
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
the result records whether truncation occurred.
## Results
Render results include:
- full argv recorded as `command`
- stdout
- stderr
- exit code
- truncation flags when applicable
Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. For direct Markdown reports, Scriptorium writes the
managed Markdown artifact to the `--out` path. For generated-text-template
reports, Scriptorium writes raw JSON to the `--out` path; later
weatherreporter workflow steps validate those bytes and render Markdown from an
embedded template.
## Failure Behavior
The adapter validates required request fields before starting Scriptorium:
- prompt ID
- data package path
- output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by
the adapter, even if Scriptorium wrote output to the requested artifact path.
Subprocess start failures, context cancellation, and timeouts return errors
without fabricating a successful result.
## Security Notes
- The adapter does not invoke a shell.
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
operationally sensitive data.
- API keys should be provided through the Scriptorium environment or
Scriptorium configuration, not through `weatherreporter` CLI arguments.

View File

@@ -1,26 +1,51 @@
# Weather API Integration
This document describes the external Weather API contract used by
`weatherreporter`.
Weatherreporter fetches normalized weather inputs from a configured Weather API
base URL. This guide defines the HTTP contract the service must satisfy; it is
not a general Weather API reference. Configuration values are defined in the
[configuration reference](../config.md). Normalization and collection behavior
are documented in [Weather data internals](../internal/weather-data.md) and
[Collection internals](../internal/collect.md).
## Purpose
## Base URL And Requests
`weatherreporter` uses a configured Weather API base URL to fetch normalized
weather source data and assemble a `weatherdata.Bundle`. This is an integration
contract for the project adapter, not a complete public API reference for the
upstream service.
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
endpoint path to the configured base URL path, so a service hosted under a path
prefix must keep that prefix available. Requests use `GET` and carry the
configured timeout on every HTTP attempt.
## Base URL
Every request sends `format` and, except where noted below, `units`. The
configured format must be `json`.
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
URL with the endpoint paths listed below. Generation and explicit bundle fetches
fail before any HTTP request when the base URL is empty or not absolute.
Before retrieving sources, Weatherreporter warms up
`/conditions/current` with the same `format`, `units`, and `precision` query
parameters used for current conditions. The warmup only requires a readable
2xx response; its body is not decoded. Failure after its internal retry budget
stops the fetch before source requests begin.
The HTTP client uses `weather_api.timeout`.
## Endpoints And Query Parameters
The adapter makes one source request for each endpoint after a successful
warmup, subject to retry on transient failures.
| Source | Endpoint | Query parameters | Availability |
| --- | --- | --- | --- |
| Observations | `/observations` | `format`, `units`, `precision` | Optional |
| Current conditions | `/conditions/current` | `format`, `units`, `precision` | Optional |
| Hourly forecast | `/forecast/hourly` | `format`, `units`, `precision`, `tz` | Required |
| Narrative forecast | `/forecast/narrative` | `format`, `units`, `precision`, `tz` | Optional |
| Active alerts | `/alerts/active` | `format`, `units` | Optional; `data: null` means checked with no active alerts |
| Forecast discussion | `/discussion` | `format`, `units`, `tz` | Optional |
| Weather story | `/weatherstories/latest` | `format` | Optional |
| SPC convective outlooks | `/outlooks/convective` | `format`, `tz` | Optional; non-null empty lists are checked empty data |
`precision` comes from `weather_api.precision`; `tz` comes from
`weather_api.timezone`. Weatherreporter does not call day-slice forecast or
discussion-subsection endpoints.
## Response Envelope
Every response used by the adapter must be JSON with a top-level `data` field:
Each endpoint response must be JSON with a top-level `data` member:
```json
{
@@ -28,171 +53,95 @@ Every response used by the adapter must be JSON with a top-level `data` field:
}
```
For most sources, `data: null` is treated as a missing source. Missing optional
sources follow the configured missing-source policy. Missing hourly forecast
data fails bundle fetching because hourly periods are required for report
generation.
An absent `data` member is treated as a missing source. For ordinary sources,
`data: null` is also missing. The active-alert exception is listed above: its
explicit `null` payload represents an empty alert result.
`/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.
Hourly forecast data must be present and contain at least one `period`; a
missing, malformed, or empty hourly product fails collection. The remaining
sources follow the configured missing-source policy. Under `error`, collection
fails; under `warn`, the source is omitted and an inspectable warning is
recorded; under `none`, the source is omitted without a warning. A per-source
policy overrides the default. See [Configuration](../config.md) for policy
settings and [Weather data internals](../internal/weather-data.md) for recorded
source metadata.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
Malformed `data` for an optional source follows its missing-source policy.
Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy.
## Payload Fields Used
## Query Parameters
Weatherreporter decodes only the fields below; additional upstream fields are
ignored. Timestamps must be JSON values accepted by Go's `time.Time` decoder.
The adapter sends these query parameters:
### Observations And Current Conditions
- `format`: from `weather_api.format`; configuration validation requires `json`
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
`/observations` uses `stationId`, `stationName`, `timestamp`, `conditionCode`,
`isDay`, `textDescription`, `temperatureC`, `temperatureF`, `dewpointC`,
`dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`, `windGustMph`,
`windDirectionDegrees`, `barometricPressurePa`, `barometricPressureInHg`,
`visibilityMeters`, `visibilityMiles`, `relativeHumidityPercent`,
`apparentTemperatureC`, `apparentTemperatureF`, and `presentWeather`.
Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
`/conditions/current` uses `conditionText`, `isDay`,
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
## SPC Convective Outlooks
### Hourly And Narrative Forecasts
The adapter fetches SPC convective outlook data from:
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
`elevationFeet`, and `periods`.
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
Each `periods` item uses `startTime`, `endTime`, `name`, `isDay`,
`conditionCode`, `textDescription`, `temperatureC`, `temperatureF`,
`temperatureCMin`, `temperatureFMin`, `temperatureCMax`, `temperatureFMax`,
`dewpointC`, `dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`,
`windGustMph`, `windDirectionDegrees`, `barometricPressurePa`,
`barometricPressureInHg`, `visibilityMeters`, `visibilityMiles`,
`apparentTemperatureC`, `apparentTemperatureF`, `cloudCoverPercent`,
`probabilityOfPrecipitationPercent`, `precipitationAmountMm`,
`precipitationAmountIn`, `snowfallDepthMM`, `snowfallDepthIn`, `uvIndex`, and
`relativeHumidityPercent`.
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
### Alerts, Discussion, And Weather Story
Run fields consumed by weatherreporter:
`/alerts/active` uses the `asOf` timestamp and keeps each item in `alerts` as
an alert payload. Weatherreporter does not require a separate alert-item schema
at this integration boundary.
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
section uses `qualifier`, `text`, and `issuedAt`.
Outlook fields consumed:
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
### SPC Convective Outlooks
Discussion fields consumed:
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
Each outlook uses `id`, `provider`, `product`, `day`, `outlookType`, `label`,
`labelText`, `forecaster`, `severityRank`, `validFrom`, `validTo`, `issuedAt`,
`expiresAt`, `sourceUrl`, `imageUrl`, `containsLocation`, and GeoJSON
`geometry`. Each discussion uses `day`, `headline`, `summary`, `discussion`,
and `updatedAt`.
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Timeouts, Retries, And Failures
## Endpoints Used
The configured Weather API timeout applies to each warmup and source HTTP
attempt. Weatherreporter retries transient transport and response-read failures
and these response statuses: `408`, `429`, `500`, `502`, `503`, and `504`.
It does not retry other HTTP statuses, malformed envelopes, missing data, or
payload decoding failures. A canceled context also stops an in-progress retry
delay.
The adapter fetches these endpoints once per bundle:
The adapter reads at most 10 MiB from one response body. A non-2xx response,
request construction failure, read failure, or decode failure includes endpoint
context in its error.
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
inside Go after the full hourly and narrative products are fetched.
## Required And Optional Sources
Hourly forecast is required:
- `data: null` for `/forecast/hourly` fails the fetch.
- an hourly forecast with no `periods` fails the fetch.
- malformed hourly data fails the fetch.
Other fetched sources are optional and follow `missing_source.default` or a
source-specific `missing_source.sources` policy:
- `observations` for `/observations`
- `current` for `/conditions/current`
- `narrative` for `/forecast/narrative`
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior:
- `error`: fail the fetch for that source
- `warn`: omit the source data, add a warning, and continue
- `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.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity
For source payloads accepted into the bundle, including the explicit `null`
alerts payload, the adapter records:
- source name
- endpoint path
- query parameters sent
- fetch time
- source issue and update timestamps when present in the payload
- SHA-256 hash of the compact raw `data` JSON
Warnings are recorded both on the affected source and on the bundle-level
warnings list.
## Compatibility Assumptions
The adapter expects payload fields compatible with the internal weather data
bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values
- current condition values
- forecast run metadata and `periods`
- active alert run data
- discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text,
and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized
bundle.
Retry counts and delays are adapter behavior rather than Weather API request
parameters. Do not depend on a particular attempt count when implementing the
service.

View File

@@ -1,233 +1,48 @@
# App Orchestration Internals
# Application Orchestration Internals
This document describes the workflow coordinator in `internal/app`.
`internal/app` owns top-level generation, batch, collection, inspection, and
notification ordering after the CLI has parsed arguments and loaded configuration.
## Purpose
## Generation
`internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, collects weather data
through `internal/collect`, builds collected and derived facts, builds module
snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
boundary, optionally notifies distributor through an app-owned notifier
boundary, persists managed state, runs batches, and reads existing artifacts
for inspection.
`GenerateDetailed` resolves one of the four report definitions, initializes an
optional debug root, and inspects the exact Promptkit prompt/profile before it
collects weather or writes managed state. It then builds facts and modules,
saves the YAML data package, persists preparation metadata from the executor
callback, executes the prepared prompt, saves execution provenance and raw
output, validates generated text, renders Markdown, and optionally copies or
notifies from the managed report.
## Inputs And Outputs
After a completed prompt run, each successfully written downstream artifact is
atomically added to the execution record before the corresponding metadata
rewrite. Later failures therefore leave the original Promptkit outcome and its
last durable set of reached paths inspectable.
Inputs:
Failure results retain all safe paths reached so far. Validation rejection
persists raw output and execution provenance but does not render a report.
- `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle collection and save workflows
- `ReportRequest` for single-report generation
- resolved report definitions from `internal/report`
- collection results from `internal/collect`
- prior snapshots loaded from `internal/state`
- optional collector, renderer, notifier, and state-store fakes for tests
## Batches
Outputs:
`RunBatchDetailed` constructs a single debug writer and uses the request's
single executor. Before collection it inspects Today, Tomorrow, and Daily for
morning, or Tomorrow and Daily for evening, deduplicating effective profile
inspection. It then collects once, plans eligible Daily dates, and calls the
same prompt-generation core sequentially for each planned report. Per-report
notification is suppressed; a failed report does not stop later reports.
- generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and
one top-level batch notification result when attempted or skipped
- saved Weather API bundle JSON for explicit bundle collection workflows
- inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance
Batch notification is skipped when disabled or when any report failed.
Successful notification uses the completed managed report paths only. Batch
items retain preparation, execution, and optional debug paths when reached.
## Boundaries
## Inspection And Boundaries
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, own fact
derivation algorithms, define report periods, compare rendered Markdown, or
construct Scriptorium argv.
Inspection loads persisted state only. It does not collect weather, invoke
Promptkit, or upload reports. The app coordinates project-owned contracts but
does not parse flags, load YAML, implement transport, construct provider SDKs,
or define report-period policy.
Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
upstream collection stays in `internal/collect`. Scriptorium subprocess
behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
stays in `internal/adapters/distributor`. Filesystem layout and persisted
metadata stay in `internal/state`.
Focused checks:
## Data Flow Terms
- `collect.Result` is the app-facing upstream collection result. It carries the
normalized `weatherdata.Bundle` used by report generation.
- `CollectedFacts` are normalized source facts derived from a collected Weather
API bundle and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected
and derived facts for prompt input and inspection.
- `GeneratedText` is structured prose returned by Scriptorium for
generated-text-template reports and validated by `internal/generatedtext`.
- `RenderContext` is the typed template input built from report metadata,
module outputs, and validated generated text before Markdown rendering.
## Config Fields Used
- `weather_api.*` for Weather API client construction and module metadata
- `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds
- `notify.distributor.*` for optional single-report and batch notification
after report generation
Output copy flags are command request fields. They are not configuration
defaults.
## Generation Workflow
Single-report commands validate the report command, collect once through
`internal/collect`, resolve the requested report, and pass the resolved report
plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
callers.
`GenerateReport` then uses this setup:
1. Create or use a filesystem store.
2. Locate any prior compatible snapshot through `internal/state`.
3. Build collected and derived facts from the supplied collection.
4. Execute configured modules and save the module snapshot.
5. Compute Recent Changes from structured prior and current module snapshots.
6. Build and save the YAML Scriptorium `data_package`.
7. Run Scriptorium render preflight.
8. Save preflight JSON when a render result is available.
9. Save metadata for inspection.
For `scriptorium_markdown` reports, generation then:
10. Runs Scriptorium report generation to the managed report path.
For `generated_text_template` reports, generation then:
10. Looks up the generated-text catalog entry for the report schema/template
IDs.
11. Runs structured Scriptorium generation to the raw generated-text JSON path.
12. Saves the structured Scriptorium run result.
13. Validates and saves normalized generated text.
14. Builds and saves a typed render context.
15. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization:
1. Copies the managed report to the requested `--out` or `--out-dir` path when
provided.
2. Saves final metadata with the managed report path and any generated-text
artifact paths already produced.
3. If distributor notification is enabled, notifies using the managed report
path as the source file.
4. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after collection,
module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
Generated-text report failures are returned with report ID, RunID, and the
failed operation. When available, the app preserves the latest generated-text
artifacts already reached by the workflow: preflight output, structured run
result, raw generated text, validated generated text, and render context.
When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted
failure context.
`--out` copies are never used as notification source files.
## Batch Workflow
`run morning` collects once, plans Today Report, Tomorrow Report, and eligible
future Daily Reports from the collected hourly forecast, then passes the same
collection into each report generation. `run evening` uses the same collection
and planning rules, but starts with Tomorrow Report. Future Daily reports start
with the day after tomorrow and require complete hourly forecast coverage for
the target local civil day. Dynamic Daily `--out-dir` copies use
`daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
A collection failure stops the batch before planning or report generation.
After planning succeeds, 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.
Batch report generation suppresses per-report distributor notification. After
all planned reports finish, app orchestration evaluates batch notification:
1. If distributor notification is disabled, the batch notification result is
omitted.
2. If batch notification is disabled, the batch notification result is omitted
and there is no per-report fallback upload.
3. If any planned report failed, the batch notification result is `skipped`
with reason `one or more reports failed`, and distributor is not called.
4. If every report succeeded, app orchestration renders batch pipeline, bundle
ID, and idempotency key templates, renders report-specific distributor
paths for each included report, validates every managed source path and
bundle path, checks duplicate bundle paths across the batch, calls the
notifier once with a multi-file request, and saves a batch notification
debug artifact.
Batch notification failure records a top-level failed notification, increments
the aggregate batch failure count, and returns an aggregate batch error without
marking individual report items failed. `--out-dir` copies are never used as
notification source files.
## 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
- Resolve errors stop the requested workflow before collection.
- Collection and module execution errors stop that report before Scriptorium
runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies.
- Single-report notification errors are wrapped with report ID, RunID, and
managed report path context. Detailed generation returns the inspectable
report, metadata, and notification artifact paths when finalization has
already saved them.
- Batch notification errors are recorded on the top-level batch notification
result and do not change individual report item status.
- Metadata and artifact path errors include filesystem context.
- Batch failures are recorded per report and surfaced through an aggregate
batch error.
## Tests
Inspect:
- `internal/app/app_test.go`
- `internal/app/batch_plan_test.go`
- `internal/collect/collect_test.go`
- `internal/cli/root_test.go`
- `internal/state/filesystem_test.go`
## Invariants
- Report behavior is resolved through `internal/report`.
- Generate and run commands collect once before report generation.
- Batch planning is app-owned because future Daily membership depends on
collected hourly forecast coverage.
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run.
- Single-report distributor notification maps the managed Markdown report path
to configured bundle paths.
- Batch distributor notification maps each included managed Markdown report
path to bundle paths rendered for that report and uploads once for the
batch.
- Extra output copies are not upload sources.
```sh
go test ./internal/app ./internal/collect
```

View File

@@ -1,160 +1,69 @@
# Module Builder Internals
This document describes module builder behavior in `internal/briefing`.
`internal/briefing` builds typed module outputs from resolved report context,
collected facts, and derived facts. It owns the module registry, including
module support, fact requirements, option types, missing-data policy, builders,
and prompt-export hooks. It does not collect data, derive periods, write a
snapshot, construct YAML, invoke Promptkit, or render a report.
## Purpose
## Registry and construction
`internal/briefing` turns report metadata, collected weather data, and derived
forecast facts into prompt-facing module outputs. The package also owns the
module registry used to validate report composition and config overrides.
Every `ModuleDefinition` declares an ID, stanza name, default option value,
required collected and derived facts, supported report IDs, missing-data
behavior, duplicate policy, builder, and optional prompt exporter.
Module outputs are structured prompt inputs. They are not rendered report prose
and they are not persisted by this package.
`BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior:
## Inputs And Outputs
- `omit` returns no output for unavailable optional facts;
- `error` returns the missing fact requirements; and
- `empty` allows the builder to emit an explicit checked-empty value.
Inputs:
Unsupported `warn` behavior, missing builders, duplicate registry IDs or
stanza names, output ID or stanza mismatches, and exporter failures all return
errors with module context. A successful builder gets a pass-through prompt
value unless its definition supplies an exporter.
- resolved report definition, generation time, timezone, and valid period
- collected facts built from `weatherdata.Bundle`
- derived daily, daypart, precipitation, alert, and storm-window facts where
required
- configured units, timezone, and descriptive location context
- typed module options from report defaults or config overrides
## Built value families
Outputs:
Source-oriented builders shape report metadata, current conditions, narrative
and hourly forecasts, alert digest, SPC outlooks and discussion, area forecast
discussion, and weather story. Derived builders shape daily and daypart
summaries, precipitation timing, outdoor windows, and the report-specific
Daily, Today, and Tomorrow planning values.
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, `today_planning`, `tomorrow_planning`, and
`daily_planning`
The module registry preserves rich values for templates and snapshots while
curating prompt exports where needed. In particular, source warnings are a
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
missing sources, and prompt-safe SPC values omit geometry and other
template-only or source details. The complete module composition is in
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
`area_forecast_discussion` accepts an optional typed section filter. Planning
modules are report-specific: `daily_planning` supports Daily,
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
package and embedded Markdown template.
## Missing data and boundaries
Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template.
Optional current conditions, narrative products, discussions, and weather
stories may be omitted. Required derived modules fail when their declared facts
are unavailable. Empty alert and outlook runs can still produce checked-empty
modules. SPC discussion is omitted unless a retained categorical outlook meets
the package's severity criterion and matching discussion text exists.
Today Report supports the Daily-style civil-day modules plus `today_planning`
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
package and embedded Markdown template.
Effective units, timezone, and location context arrive in `ModuleContext` from
configuration and resolved report metadata. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by
[prompt input](prompt-input.md).
`today_planning` is a Today-specific deterministic planning stanza with
morning readiness, commute/school/workday concerns, outdoor planning, and
late-day change-watch fields. It is compatible with `report.Today` only.
## Verification and invariants
`daily_planning` is a dated Daily deterministic planning stanza with morning
readiness, commute/school/workday concerns, and overnight change-watch fields.
It is compatible only with the `daily` report ID value. The default Daily
Report composition includes it.
Focused tests cover source and derived values, registry validation, option
handling, prompt exporters, support rules, and missing-data behavior:
Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
`today_planning`, `tomorrow_planning`, or `daily_planning`.
```sh
go test ./internal/briefing
```
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
## Boundaries
- This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option
types.
- It does not collect weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata.
## Config Fields Used
The app layer passes effective units, timezone, and location context into the
module context. `internal/facts` consumes daypart configuration before module
builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`; Daily Report defaults it to `long_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It also emits a curated `risk_digest`
for categorical outlooks that overlap the report period, contain the location,
and meet the configured-in-code minimum severity for report rendering. It does
not emit GeoJSON geometry, source URL, expiration time, or severity rank.
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained categorical
outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used
None directly.
## State Or Manifest Behavior
None. `internal/app` collects module outputs into a `module.Snapshot`, and
`internal/state` persists that snapshot.
## Skip And Resume Behavior
None. Builders either emit a module output, omit optional unavailable data, or
return an error for invalid required inputs.
## Failure Behavior
- Required derived modules return errors when their dependent facts are not
available.
- Module registry construction rejects duplicate module IDs and duplicate
stanza names.
- Composition validation rejects unknown modules, duplicate modules,
incompatible report/module combinations, duplicate stanza names, and invalid
option shapes.
- Source-oriented module builders omit missing optional current conditions,
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests
Inspect:
- `internal/briefing/base_modules_test.go`
- `internal/briefing/derived_modules_test.go`
- `internal/briefing/modules_test.go`
- `internal/app/app_test.go`
## Invariants
- Module outputs contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, source warnings, and configured prompt location.
- Prompt input packaging and Scriptorium execution remain outside this package.
Builders emit structured facts, never report prose. The app collects their
outputs into a module snapshot, and state persists that snapshot.

View File

@@ -1,75 +1,56 @@
# Changes Internals
This document describes structured Recent Changes comparison.
`internal/changes` deterministically compares a compatible prior module
snapshot with the current snapshot. It returns compact structured changes for
prompt input; it never reads state, finds a prior report, renders Markdown, or
compares generated text. Snapshot construction belongs to
[module internals](module.md), and prior-snapshot discovery belongs to
[state internals](state.md).
## Purpose
## Comparison inputs and output
`internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages.
Each comparator receives a prior snapshot, a current snapshot, and
`Thresholds`. A `Change` has a stable type and message plus previous and
current values where useful. Changes are sorted by type and then message, so
the same inputs always yield the same order.
## Inputs And Outputs
Threshold values are supplied by application orchestration from the
[Recent Changes configuration](../config.md#recent_change); this package does
not load configuration or choose defaults. Numeric changes are emitted when
the absolute difference meets the configured threshold. Precipitation also
requires a change between its low, possible, likely, and high categories.
Inputs:
## Strategies
- prior module snapshot
- current module snapshot
- comparison thresholds from configuration
| Comparator | Required snapshot data | Compared values |
| --- | --- | --- |
| `CompareDaily` | `derived_daily_summary`, `derived_daypart_summaries` | Low and high temperature, daily precipitation probability and timing, peak gust, alerts, and aggregate indicators |
Outputs:
For daily comparison, `alert_digest` and `precip_timing` are optional: alerts
are compared when present, and timing is compared only when both snapshots
contain it.
- ordered `changes.Change` items with type, message, previous value, and current
value where useful
The application selects a comparator only after state lookup establishes a
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator.
Hourly reports do not produce a Recent Changes list.
## Boundaries
## Missing data and failures
- This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text.
Required stanzas that are absent or cannot be decoded return an error with the
snapshot and stanza context. Optional stanzas may be absent. A snapshot with no
eligible predecessor is not a comparison failure: the caller supplies an empty
change list without invoking this package.
## Config Fields Used
The package has no filesystem, transport, CLI, renderer, or persistence
behavior. It does not decide report compatibility or retain snapshots.
The app maps these fields into comparison thresholds:
## Verification and invariants
- `recent_change.temperature_degrees`
- `recent_change.precip_probability_points`
- `recent_change.wind_gust_miles_per_hour`
- `recent_change.precip_timing_shift_minutes`
Focused tests cover the daily strategy, threshold boundaries, indicator and
alert changes, and missing required stanzas:
## External Adapters Used
```sh
go test ./internal/changes
```
None.
## State Or Manifest Behavior
None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions.
## Skip And Resume Behavior
No resume behavior. When the app has no prior comparable snapshot, it sends an
empty Recent Changes list without calling a comparison function.
## Failure Behavior
- Daily comparison requires `derived_daily_summary` and
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
`precip_timing` when present.
- 3-Day comparison requires `derived_daypart_summaries`.
- Weekend comparison requires `derived_daypart_summaries`.
- Storm Report comparison returns no changes.
## Tests
Inspect:
- `internal/changes/daily_test.go`
- `internal/changes/three_day_test.go`
- `internal/changes/weekend_test.go`
- `internal/app/app_test.go`
## Invariants
- Recent Changes are based on structured snapshots, not Markdown report text.
- Report compatibility is determined outside this package by report definitions
and state lookup.
- Output stays compact enough for prompt input.
Recent Changes always compare structured snapshot values, never report prose.

View File

@@ -1,67 +1,27 @@
# CLI Internals
This document describes command output ownership in `internal/cli`.
`internal/cli` parses terminal arguments, loads configuration, constructs app
requests, and translates app results to bounded JSON summaries. The user
contract belongs in the [CLI reference](../cli.md).
## Purpose
The root `--version` flag reports the build version supplied by
`internal/buildinfo`. Tagged release builds replace its development default at
link time.
`internal/cli` owns command parsing, app request construction, help text, and
presentation of command results. It converts app-layer results into stable CLI
summaries and writes stdout/stderr through shared output helpers.
For each `generate` or `run` action, `Runner` constructs one project-owned
Promptkit executor after configuration loads. It passes the executor and any
`--llm-debug-dir` request into the app. `run` accepts the debug flag as well
as `generate`; the app, not the CLI, secures and initializes the debug root.
## Command Categories
Summaries include identity, status, safe artifact paths, and notification
provenance. They intentionally exclude module values, YAML package bodies, raw
generated text, rendered prompts, schemas, endpoints, credentials, and full
Distributor payloads. A failed action with a partial result still emits its
safe summary before its error is returned.
- Action commands: `generate` and `run`. These perform work, write artifacts,
and return compact summaries.
- Inspection commands: `inspect reports`, `inspect metadata`, `inspect
modules`, `inspect data-package`, `inspect prior`, and `inspect sources`.
These read existing artifacts and return requested data.
CLI code owns no report policy, weather collection, persistence, provider
execution, or notification policy. Focused checks:
Future commands must declare which category they belong to before adding output
behavior.
## Stdout And Stderr
Action commands write JSON summaries to stdout by default. `run` also writes
compact status lines to stderr through `writeBatchStatus`. `generate` does not
write routine stderr today. Pre-run errors return without partial JSON.
Inspection commands write requested JSON data to stdout with `writeJSON`. They
do not use action output helpers and do not support quiet mode.
Returned errors are not hidden by output helpers. The caller remains
responsible for displaying command errors.
## Quiet Mode
`--quiet` is supported only by action commands. It suppresses successful stdout
and routine stderr by passing `outputOptions{Quiet: true}` to
`writeActionResult`. It does not suppress returned errors.
Quiet mode is intentionally not accepted by inspection commands because
inspection stdout is the command result.
## Summary Ownership
CLI-safe summary structs live in `internal/cli/result.go`.
- `newGenerateSummary` converts `*app.ReportResult` plus an optional error into
the generate JSON contract.
- `newBatchSummary` converts `*app.BatchResult` into the run JSON contract and
derives the top-level run status.
Summary types must not expose full app internals, module contents, data package
contents, raw generated text, Scriptorium result bodies, or full distributor
payloads.
## Helper Path
New action commands should:
1. parse command-specific flags into CLI option structs;
2. call the app-layer use case;
3. convert app results into a CLI summary type;
4. write through `writeActionResult`;
5. use a status writer only for routine stderr status lines.
New inspection commands should call the app inspection use case and write the
returned data through `writeJSON`.
```sh
go test ./internal/cli
```

View File

@@ -1,58 +1,43 @@
# Collection Internals
This document describes the app-facing upstream collection boundary in
`internal/collect`.
## Purpose
`internal/collect` is the canonical package used by app workflows to collect
upstream Weather API data. It constructs the Weather API adapter, fetches a
normalized bundle, and returns that bundle without applying report selection or
batch policy.
`internal/collect` is the application-facing boundary for collecting the
normalized Weather API bundle. The external HTTP contract belongs in the
[Weather API integration guide](../integrations/weatherapi.md); normalized data
semantics belong in [weather-data internals](weather-data.md).
## Contract
Inputs:
`Run` accepts a `context.Context` and a `Request` containing effective
`config.Config`. It constructs the Weather API adapter from that configuration,
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
- `collect.Request`, containing the effective `config.Config`
- `context.Context` for cancellation
The package wraps adapter construction failures as weather-collection setup
errors and fetch failures as bundle-collection errors. It does not retry,
persist, select reports, derive facts, build modules, invoke Promptkit, or
notify Distributor.
Output:
## Application Composition
- `collect.Result`, containing `*weatherdata.Bundle`
`internal/app` owns the narrow `Collector` interface used by workflow tests;
the production implementation delegates to `collect.Run`. Generation, batch
execution, and explicit bundle fetching all use this boundary. Application
orchestration rejects a nil collector result or a nil bundle before report work
can continue.
`Run` returns an actionable error when Weather API adapter construction or
bundle fetch fails. The package does not derive `facts.CollectedFacts`, build
modules, resolve report periods, select reports, write state, invoke
Scriptorium, or notify distributor.
Single-report generation and a batch each collect once. A batch passes the same
normalized collection to planning and to every report it generates. Collection
failure prevents later workflow work for that request.
## App Usage
## Boundaries And Invariants
`internal/app` owns a narrow `Collector` interface for orchestration tests. The
default implementation calls `collect.Run`.
Collection owns adapter creation and retrieval of one normalized bundle. It
must not make report, period, batch, prompt, module, filesystem, or notification
decisions.
Single-report generation collects once, resolves the requested report, and
passes the explicit collection into report generation. Batch generation
collects once before planning and passes the same collection into each planned
report. If collection returns no bundle, app orchestration returns an error
before report generation.
- App-facing Weather API collection always passes through this package.
- The returned value is normalized source data, not facts or prompt input.
- Context cancellation is passed to the Weather API adapter.
- Errors retain whether setup or fetching failed.
## Boundaries
Weather API HTTP details stay in `internal/adapters/weatherapi`. The collection
package returns normalized `weatherdata` only. It must not know about report
IDs, prompt IDs, batch names, Daily eligibility, module composition, Recent
Changes, state paths, or Scriptorium arguments.
## Tests
Inspect:
- `internal/collect/collect_test.go`
- `internal/app/app_test.go`
## Invariants
- App-facing Weather API collection goes through `internal/collect`.
- Collection returns normalized source data, not report facts or prompt input.
- Report and batch policy belongs outside `internal/collect`.
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
also covered by `internal/app/app_test.go`.

View File

@@ -1,134 +1,63 @@
# Distributor Adapter Internals
This document describes the distributor upload adapter in
`internal/adapters/distributor`.
`internal/adapters/distributor` translates a local delivery request into the
Distributor Go client's upload and status calls, then returns a local delivery
result. The external API, authentication, and idempotency contract is owned by
the [Distributor API guide](../integrations/distributor/api.md) and
[Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
## Purpose
## Client construction
The adapter submits generated weatherreporter Markdown reports to a configured
distributor HTTP upload endpoint. It supports one or more file mappings per
upload request. It isolates distributor package types, token-env lookup, upload
client construction, source-bundle file mapping, timeout handling, status
polling, and upload error wrapping from app orchestration.
`Client` holds the endpoint, the name of the environment variable containing
the token, an optional timeout, and an injectable upstream-client factory.
`New` validates its configuration before creating the adapter. For each upload,
the adapter reads the token from the configured environment variable and builds
the upstream client with that endpoint, token, and an HTTP client whose timeout
matches the local positive timeout.
## Inputs And Outputs
The upstream client is an implementation dependency, not a source of
application configuration: retry ownership, pipeline selection, path
templates, and report rendering are defined by
[configuration](../config.md) and [application orchestration](app-orchestration.md).
Inputs:
## Upload translation
- distributor endpoint URL
- token environment variable name
- upload timeout
- pipeline ID
- bundle ID
- idempotency key
- source Markdown report paths and bundle-relative path mappings
- bundle created timestamp
- context for cancellation
Before calling the dependency, `Upload` validates the endpoint and token
configuration plus the local pipeline ID, bundle ID, idempotency key, and every
file's source and bundle paths. It maps the request as follows:
Outputs:
| Local request | Distributor client value |
| --- | --- |
| Pipeline ID | Upload pipeline identifier |
| Bundle ID | Bundle identifier |
| Idempotency key | Upload idempotency key |
| File source and bundle paths | Bundle file entries |
| Creation timestamp | Bundle creation time |
- accepted distributor run ID
- accepted distributor upload status
- distributor run status, status polling error, and raw run report JSON when available
- weatherreporter-owned idempotency conflict error when applicable
The call inherits the caller's context and applies the configured positive
timeout. The adapter does not read report files, construct bundle layouts, or
persist notification artifacts.
## Boundaries
## Status and errors
`internal/adapters/distributor` is the only weatherreporter package that imports
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
An accepted upload is followed by one status request. When a timeout is
configured, a nonterminal result is polled until `succeeded` or `failed`, or
until the context ends. The translated `UploadResult` contains the run ID,
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
and remote error details.
The app layer passes weatherreporter-owned request values to the adapter. The
adapter does not choose report types, render templates, select output copies,
decide whether an upload represents one report or a batch, configure
destinations, wait for downstream publication, transform Markdown, or persist
notification state.
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
the caller can record an accepted-but-unconfirmed delivery. A terminal failed
run returns that result and an error. Upload failures return no result. Upstream
idempotency conflicts become the local `IdempotencyConflictError`, which adds
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
the token.
Full upstream distributor package and HTTP contract details stay under
`docs/integrations/distributor/`.
## Verification
## Config Fields Used
Focused tests cover configuration validation, request mapping, timeouts and
polling, status translation, conflict handling, and token redaction:
The adapter is built from `notify.distributor` config:
- `endpoint`
- `token_env`
- `timeout`
The app layer renders single-report pipeline ID, bundle ID, idempotency key,
and bundle paths from:
- `pipeline_id_template`
- `bundle_id_template`
- `idempotency_key_template`
- report-specific path templates
For batch uploads, the app layer renders pipeline ID, bundle ID, and
idempotency key from `notify.distributor.batch.*`, resolves report-specific
path templates once per included report, and passes the resulting multi-file
request to this adapter.
Report-specific path resolution happens entirely in the app layer. Explicit
`reports.<report>.distributor.path_templates` overrides take precedence over
report definition defaults.
The token value is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing.
## Upload Behavior
The adapter calls distributor `UploadFiles` with one or more file mappings:
- pipeline ID: the rendered distributor workflow selector
- source paths: managed Markdown report paths selected by app orchestration
- bundle paths: rendered bundle-relative report paths for each source
- created: the report or batch generation timestamp
The adapter creates a distributor upload client with the configured endpoint,
bearer token, and timeout-backed HTTP client. It also wraps the upload context
with the configured timeout when the timeout is greater than zero.
After upload acceptance, the adapter polls distributor `Status` for the accepted
run ID until the run reaches `succeeded` or `failed`, or until the configured
timeout expires. It returns the latest status, error text, and raw report JSON in
weatherreporter-owned types so app orchestration can persist them in the
notification debug artifact. Status lookup failures or timeout before a terminal
state are kept as debug status errors on an otherwise accepted upload. A
terminal distributor run status of `failed` is returned as a notification failure
with the status report preserved.
## Failure Behavior
The adapter validates required endpoint, token env name, token value, pipeline
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
upload client inputs before uploading.
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
source paths, and bundle paths context. Token values are redacted from adapter
errors.
Distributor idempotency conflicts are exposed as a weatherreporter-owned
`IdempotencyConflictError`, so callers do not depend on upstream distributor
types.
## Tests
Inspect:
- `internal/adapters/distributor/client_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
Adapter tests use a fake upload client factory and do not require a live
distributor service.
## Invariants
- Distributor package types do not leak outside the adapter.
- Only managed Markdown report paths selected by app orchestration are
uploaded.
- The adapter never scans the workspace.
- Token values are not included in errors, CLI output, metadata, docs, or
examples.
- Destination routing and Markdown-to-HTML transformation belong to
distributor, not weatherreporter.
```sh
go test ./internal/adapters/distributor
```

View File

@@ -1,94 +1,64 @@
# Fact Contracts Internals
This document describes the fact contract boundary.
`internal/facts` is the deterministic boundary between a collected weather
bundle and report-scoped facts. It preserves normalized source values and then
selects and summarizes the values needed for one resolved report. Provider
transport and normalized bundle semantics belong to
[weather-data internals](weather-data.md); report identity and valid-period
selection belong to [report registry internals](report-registry.md).
## Purpose
## Collected facts
`internal/facts` separates normalized upstream facts collected for a report run
from conservative report-scoped facts derived from them. The package gives app
orchestration one place to build reusable facts before module execution.
`BuildCollected` projects a `weatherdata.Bundle` into `CollectedFacts`. It
retains the fetched timestamp and every normalized product: observations,
current conditions, hourly, narrative, alerts, discussion, daily, weather
story, and convective outlook data. Source provenance and warnings are copied
into their own slices so downstream consumers can inspect data completeness
without treating it as an ordinary weather fact.
## Inputs And Outputs
A nil bundle produces an empty collected value. Collection itself, missing
source policy, and source hashes are outside this package.
Inputs:
## Report-scoped derivation
- `weatherdata.Bundle` from the Weather API adapter
- resolved report definition and valid period
- report timezone
- configured daypart definitions
`BuildDerived` requires a valid resolved period and a valid report timezone. It
uses half-open period overlap to select hourly, narrative, daily, and alert
data; it also derives precipitation timing. Convective outlooks are retained
only when their valid interval overlaps the report period, with discussions
kept for represented outlook days. Both collections are sorted deterministically.
Outputs:
Report identity controls the summary shape:
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
| Report family | Derived summary |
| --- | --- |
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
`DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md).
## Boundaries
## Missing data and failures
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
Optional normalized products remain nil or yield empty selections; the package
does not create substitute values. A present convective-outlook run with no
matching outlooks produces non-nil empty outlook and discussion slices, while
a missing run produces nil slices.
## Config Fields Used
Derivation fails for an invalid report period, invalid timezone, unsupported
report ID, or when a requested daily summary has no hourly forecast data.
Invalid daypart definitions surface from forecast derivation. The package does
not access the CLI, filesystem, subprocesses, or network.
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
- `weather_api.timezone`
## Verification and invariants
## External Adapters Used
Focused tests cover collected-fact separation, report-period selection,
hourly behavior, daily summaries, and convective outlook selection:
None directly. Collected facts are built from `weatherdata.Bundle`.
```sh
go test ./internal/facts
```
## State Or Manifest Behavior
None. Source provenance and warnings remain data fields for downstream metadata
and inspection.
## Failure Behavior
- Invalid or missing report valid periods return an error.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Hourly Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests
Inspect:
- `internal/facts/facts_test.go`
- `internal/app/app_test.go`
## Invariants
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.
Facts are derived once for a resolved report from already collected data.
They remain reusable structured values: prompt wording, state persistence,
prior-report comparison, and template presentation are owned elsewhere.

View File

@@ -1,76 +1,63 @@
# Forecast Derivation Internals
This document describes deterministic forecast summarization in
`internal/forecast`.
`internal/forecast` deterministically selects and summarizes normalized
forecast data. It has no transport, filesystem, CLI, subprocess, or report
registry dependency. Its summaries are consumed by
[fact contracts](facts.md) and later module builders.
## Purpose
## Period and daypart semantics
`internal/forecast` converts normalized weather data into daily and period
summaries used by fact builders and module builders.
Selections use `timeutil.Period` half-open overlap: a value is selected only
when both intervals share time. `BuildDailySummary` creates one local civil
day; `BuildPeriodDailySummaries` intersects every local civil day with the
requested period, preserving partial first and last days.
## Inputs And Outputs
`ResolveDayparts` converts each configured name, start clock, and end clock
into a local window. An end clock at or before its start clock wraps into the
next civil day. The daypart and timezone defaults are defined in the
[configuration reference](../config.md), not here.
Inputs:
## Deterministic summaries
- `weatherdata.Bundle`
- local date or resolved report period
- timezone
- configured daypart definitions
`BuildDailySummary` requires an hourly run with at least one period. It adds
the selected narrative periods, discussion, alert overlaps, source provenance,
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
its selected hourly periods and derives temperature and apparent-temperature
ranges, timed precipitation and wind maxima, dominant and notable conditions,
and weather indicators.
Outputs:
Indicators are deterministic checks over normalized values and condition text:
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
wind text are detected from the forecast description. `BuildPrecipTiming`
sorts periods, records the maximum and first precipitation, groups contiguous
periods at or above its package-owned probability threshold, and records
thunder mentions.
- `forecast.DailySummary` for one local civil day
- one clipped daily summary per local day or partial day from
`BuildPeriodDailySummaries`
- daypart summaries with selected hourly periods, ranges, timed maximums,
conditions, indicators, and alert overlaps
Alert overlap parsing supports the normalized alert payload's available timing
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
are clipped to the requested period and ordered by alert start time.
## Boundaries
## Missing data and failures
- This package groups, selects, and summarizes already-normalized forecast
data.
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
Empty selections yield empty summary fields rather than generated prose.
Direct daily or period-summary calls fail when their required bundle, valid
period, hourly data, or daypart definitions are invalid. A nil location uses
UTC when these APIs are called directly. Optional narrative, discussion, and
alerts remain absent when their normalized products are absent.
## Config Fields Used
Forecast thresholds used for brief indicators and precipitation timing are
implementation rules. User-configurable Recent Changes thresholds are applied
by [changes internals](changes.md), whose defaults are documented in
[configuration](../config.md).
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
## Verification and invariants
Threshold constants for basic indicators live in forecast code rather than
configuration.
Focused tests cover local civil days, clipped periods, daypart resolution,
summary metrics, precipitation windows, threshold helpers, and alert overlap:
## External Adapters Used
```sh
go test ./internal/forecast ./internal/timeutil
```
None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and module output.
## Skip And Resume Behavior
None. Missing optional source context can produce empty selections, but missing
required hourly data fails summarization.
## Failure Behavior
- A nil bundle or missing hourly forecast data returns an error.
- Invalid daypart definitions return parse errors with context.
- Alert records without parseable RFC3339 timing are skipped.
- Empty selected periods produce empty summaries rather than generated prose.
## Tests
Inspect:
- `internal/forecast/derive_test.go`
- `internal/timeutil/periods_test.go`
## Invariants
- Go owns report-period selection and meteorological summarization.
- Weather facts come from normalized source data.
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.
The package preserves normalized inputs as inspectable structured values and
never decides report identity, delivery, or presentation wording.

View File

@@ -1,149 +1,51 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
`internal/generatedtext` validates the structured prose produced for generated-
text reports and turns validated prose plus rich module values into typed render
contexts. It owns the catalog that pairs a generated-text report definition
with its validator, schema ID, template ID, and context builder. The complete
maintainer-facing context fields belong to [report templates](../templates.md).
## Purpose
## Catalog and validation
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets.
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated text. `LookupDefinition` rejects unknown schema or template IDs and
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
value and canonical normalized JSON, loads its canonical schema through
`internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`.
## Inputs And Outputs
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
and a single trimmed discussion string. Each form permits optional trimmed
precipitation-timing and confidence prose. Typed decoding rejects unknown JSON
fields; no general-purpose JSON Schema engine is used at runtime.
Inputs:
## Render contexts
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated generated text
The catalog's report-specific builders receive briefing metadata, a rich module
snapshot, collected facts, derived facts, and the matching validated generated
text. They decode the module stanzas needed by the template and build typed
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
metadata and periods, preserves rich module values, and uses ordered slices for
template iteration rather than maps.
Outputs:
Optional source stanzas become nil or fallback context fields. Missing required
stanzas, type-decoding failures, invalid metadata, or a generated-text type
that does not match the chosen handler fail before template execution. Prompt
packages, raw Promptkit output, state persistence, and template asset lookup
remain outside this package.
- typed `Daily` generated text
- typed `Today` generated text
- typed `Tomorrow` generated text
- typed `Hourly` generated text
- normalized stable JSON for validated generated text
- typed `DailyRenderContext` values for `internal/reporttemplate`
- typed `TodayRenderContext` values for `internal/reporttemplate`
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use
`generated_text_template`
## Verification and invariants
## JSON Contracts
Focused tests cover the catalog, each report-specific validator, normalization,
schema/template mismatches, context construction, optional modules, and typed
stanza errors:
Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```sh
go test ./internal/generatedtext
```
The day-style contract requires `summary` after trimming whitespace.
`forecast_discussion` must contain at least one nonblank paragraph after
trimming blank items. `precipitation_timing` and `confidence` are optional and
omitted from normalized JSON when blank. Unknown fields are rejected.
The report-specific Go API is:
| Report | Type | Validator | Schema ID | Template ID | Prompt ID |
| --- | --- | --- | --- | --- | --- |
| Daily Report | `Daily` | `ValidateDaily` | `daily` | `daily` | `weather.daily_generated_text` |
| Today Report | `Today` | `ValidateToday` | `today` | `today` | `weather.today_generated_text` |
| Tomorrow Report | `Tomorrow` | `ValidateTomorrow` | `tomorrow` | `tomorrow` | `weather.tomorrow_generated_text` |
Hourly generated text uses the same top-level field names, but
`forecast_discussion` is a single string:
```json
{
"summary": "string",
"forecast_discussion": "string",
"precipitation_timing": "string",
"confidence": "string"
}
```
Hourly `summary` and `forecast_discussion` are required after trimming
whitespace. `precipitation_timing` and `confidence` are optional and omitted
from normalized JSON when blank. Unknown fields are rejected. The Hourly catalog
entry uses type `Hourly`, validator `ValidateHourly`, schema ID `hourly`,
template ID `hourly`, and prompt ID `weather.hourly_generated_text`.
## Render Contexts
Daily, Today, Tomorrow, and Hourly render contexts all include:
- display metadata derived from report metadata;
- validated generated text;
- typed module outputs decoded from the module snapshot;
- collected facts;
- derived facts.
Daily, Today, and Tomorrow share common civil-day render-context fields such as
forecast date labels, valid period, generated-at labels, current conditions,
hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
discussion, weather story, daily summary, and ordered daypart summaries.
Each civil-day report keeps its report-specific planning module:
- Daily exposes `DailyPlanning`.
- Today exposes `TodayPlanning`.
- Tomorrow exposes `TomorrowPlanning`.
Today's ordered daypart context omits unavailable or elapsed dayparts according
to Today report rules. Daily and Tomorrow use fallback daypart behavior.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It owns generated-text catalog lookup for schema/template combinations.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or own embedded template/schema files.
- It renders through `internal/reporttemplate`; embedded asset lookup remains
in `internal/reporttemplate`.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required fields fail after trimming whitespace.
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
paragraphs remain.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
- Unsupported generated-text schema IDs, template IDs, or schema/template
combinations fail during catalog lookup with report ID context.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/daily_test.go`
- `internal/generatedtext/today_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Generated-text-template reports must have one catalog entry matching their
report definition schema and template IDs.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.
Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. Every report definition must resolve to exactly one
supported catalog pair.

View File

@@ -1,288 +1,66 @@
# Module Contract Internals
This document describes the module contract in `internal/module`.
`internal/module` defines the stable envelope between report composition,
module builders, snapshots, comparisons, templates, and prompt packages. It
does not define a report, execute a builder, or choose prompt-export policy;
those responsibilities belong to [report registry](report-registry.md) and
[briefing](briefing.md).
## Purpose
## Outputs and snapshots
`internal/module` defines the shared identifiers and data envelopes used for
report modules. Report definitions use module IDs for composition, module
builders produce rich outputs with stanza names, prompt input packages consume
runtime prompt export values, and Recent Changes compares snapshot stanzas.
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
`PromptValue`. `DataPackageValue` returns the prompt value when present and
otherwise the rich value. This permits custom prompt exports without shrinking
the template and inspection value.
## Inputs And Outputs
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
validates it. Snapshot JSON persists IDs, stanza names, and rich values only;
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
stanza into a caller-supplied type, reporting a missing stanza separately from
a decoding error.
Inputs:
Snapshots reject missing schema versions, empty IDs or stanza names, and
duplicate IDs or stanza names. Output order is caller-owned and preserved.
- ordered `module.ConfigItem` values from report definitions or config
overrides
- `module.Output` values produced by module builders
## Registered IDs and default composition
Outputs:
The registered IDs are `metadata`, `current_conditions`,
`narrative_forecast`, `hourly_forecast`, `derived_daily_summary`,
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
`spc_convective_outlooks`, `area_forecast_discussion`,
`spc_convective_discussion`, `weather_story`, `outdoor_windows`,
`today_planning`, `tomorrow_planning`, and `daily_planning`.
- stable `module.ID` constants
- typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value
- runtime-only prompt export values on module outputs
- `module.Output.DataPackageValue`, which selects the prompt export value and
falls back to the rich value for hand-built or loaded snapshots
- typed stanza lookup through `module.StanzaValue`
The registry declares these ordered default compositions:
## Rich Values And Prompt Exports
| Report | Ordered modules |
| --- | --- |
| Daily | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD (long term), SPC discussion, weather story, outdoor windows, daily planning, hourly forecast |
| Today | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, hourly forecast, today planning |
| Tomorrow | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, tomorrow planning, hourly forecast |
| Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
Each `module.Output` has two value surfaces:
The only non-empty default option is the AFD section selection. It accepts a
`sections` list; omitted or empty selects all available sections. Report
definitions may narrow it as shown above. Option shape and report compatibility
are validated by the briefing registry.
- `Value`: the rich module value used by templates, module snapshots,
inspection, Recent Changes, and render contexts.
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
data packages.
## Rich and prompt-facing values
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
module snapshots keep only the rich `value` field so inspection and
render-context reconstruction keep the full deterministic template surface.
Rich values remain available to snapshots, comparisons, and render contexts.
Briefing attaches custom prompt exports only for current conditions, hourly
forecast, and derived daypart summaries; all other current builders use
pass-through values. The prompt package owns how exported stanzas are grouped
and serialized; see [prompt input](prompt-input.md).
The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value.
Modules with custom prompt export policy own typed prompt export structs near
the module builder. Custom prompt exports are:
## Verification and invariants
- `current_conditions`
- `hourly_forecast`
- `derived_daypart_summaries`
Focused tests cover snapshot validation and order, typed stanza lookup, and
prompt-value fallback:
Custom exporters remove template-only helpers or confusing duplicates from the
data package without shrinking the rich module structs used by templates.
Exporter failures include module ID and stanza context.
## Registered Module IDs
The registry recognizes these IDs:
- `metadata`
- `current_conditions`
- `narrative_forecast`
- `hourly_forecast`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story`
- `outdoor_windows`
- `today_planning`
- `tomorrow_planning`
- `daily_planning`
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
## Daily Composition
The default Daily Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `daily_planning`
14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation. Its `area_forecast_discussion`
item is configured to include only `long_term`.
## Today Composition
The default Today Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `hourly_forecast`
14. `today_planning`
The embedded Today template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Tomorrow Composition
The default Tomorrow Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `tomorrow_planning`
14. `hourly_forecast`
The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Daily Planning
`daily_planning` emits dated daily planning facts for the `daily` report ID.
Its output stanza is also named `daily_planning`. The module is supported only
by that report ID and depends on daily summaries for the selected local civil
day. The default Daily Report composition includes it.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `overnight_change_watch`
The type is `briefing.DailyPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Today Planning
`today_planning` emits current-day planning facts for Today Report. Its output
stanza is also named `today_planning`. The module is supported only by Today
Report and depends on daily and daypart summaries for the current local civil
day.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `outdoor_planning`
- `late_day_change_watch`
The type is `briefing.TodayPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Hourly Composition
The default Hourly Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options
Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml
sections:
- product
- key_messages
- short_term
- long_term
```sh
go test ./internal/module
```
An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition
validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
- `risk_digest`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
The optional `risk_digest` list is a curated report-rendering subset of
categorical outlooks that overlap the report period, contain the configured
location, and meet the minimum severity threshold. Entries include `label_text`,
`risk_label`, `period_begins`, and `period_ends`; they do not expose severity
rank.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and
`discussions`; each discussion may include `day`, `period_begins`,
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries
- This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, collect weather data, derive
forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving
output order. Snapshot JSON contains rich module values only; runtime prompt
export values are not persisted.
## Failure Behavior
- Snapshot construction fails for duplicate module outputs or duplicate stanza
names.
- Typed stanza lookup returns `found=false` for missing stanzas.
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
## Tests
Inspect:
- `internal/module/module_test.go`
- `internal/briefing/modules_test.go`
- `internal/report/period_test.go`
## Invariants
- `internal/module` does not import `internal/report`.
- Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one rich typed
value.
- Built module outputs have a data-package value, either from a custom prompt
exporter or from default pass-through behavior.
- Snapshot output order is caller-owned and preserved.
Module IDs and stanza names are stable, every emitted output has one of each,
and this package never imports the report registry.

View File

@@ -1,177 +1,61 @@
# Prompt Input Internals
This document describes YAML prompt data package construction in
`internal/promptinput`.
`internal/promptinput` converts report metadata, an ordered module snapshot,
Recent Changes, and source warnings into the YAML `data_package` consumed by
Promptkit. It owns this package's schema, grouping, serialization, loading,
and validation—not weather collection, module construction, path choice, or
provider execution.
## Purpose
## Package construction
`internal/promptinput` converts report metadata, ordered module outputs, Recent
Changes, and source warnings into the `data_package` file passed to
Scriptorium.
`Build` produces `weatherreporter.data_package.v3`. It copies the run ID;
report ID, variant, prompt ID, generation time, timezone, local current date,
and valid period; ordered briefing stanzas; Recent Changes; and source
warnings. A nil Recent Changes slice becomes an empty `items` list.
The persisted data package is YAML with schema version
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
used for inspection and comparison. Data packages serialize each module
output's prompt export value, not necessarily the full rich module value saved
in the module snapshot.
Briefing starts as a flat snapshot order and stanza-value map. `Build` uses
each output's `DataPackageValue`, so runtime prompt exports take precedence and
rich values are used only as a fallback. Prompt exports are selected by the
[briefing registry](briefing.md), while the rich-versus-prompt contract is in
[module internals](module.md).
## Inputs And Outputs
## YAML ordering and grouping
Inputs:
Serialization keeps `metadata` directly under `briefing`. Every other known
stanza is placed in exactly one category, emitted in category order and in its
original snapshot order within that category:
- report metadata from app/state orchestration
- `module.Snapshot`
- optional `[]changes.Change`
| Category | Current stanzas |
| --- | --- |
| `applicable_risk_products` | alert digest, SPC convective outlooks |
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
| `narrative_products` | narrative forecast, discussions, and weather story |
| `raw_data` | current conditions and hourly forecast |
Outputs:
This YAML presentation does not alter the flat snapshot model. `LoadYAML`
accepts the same category layout and reconstructs flat `Order` and `Values`,
rejecting misplaced, duplicate, unknown, or uncategorized stanzas.
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
## Validation and persistence
The YAML shape includes:
`Validate` requires the current schema version, run and report identifiers,
prompt ID, generation timestamp, timezone, current local date, valid period,
and at least one ordered briefing stanza. It rejects duplicate stanza names,
missing values, and a missing category for every non-metadata stanza.
```yaml
schema_version: weatherreporter.data_package.v3
run_id: <run_id>
report:
id: <report_id>
prompt_id: <prompt_id>
briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
spc_convective_outlooks: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
hourly_forecast: {}
recent_changes:
items: []
`MarshalYAML` and `LoadYAML` validate their result. `Save` writes the serialized
YAML atomically; managed workspace paths are owned by [state internals](state.md).
Generated-text artifacts and template render contexts are later workflow
artifacts, not members of this package.
## Verification and invariants
Focused tests cover construction, curated exports, category ordering, YAML
round trips, invalid layout, validation, and atomic saves:
```sh
go test ./internal/promptinput
```
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
## Module Export Boundary
Data packages are curated prompt inputs. They are not full template render
contexts and should not be treated as a dump of every field available to Go
templates.
When module outputs are built by `internal/briefing`, the registry attaches a
runtime prompt export value. `internal/promptinput` serializes
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
prompt export and falls back to the rich `Value` when no prompt export is set,
which keeps loaded snapshots and hand-built tests usable.
Modules without custom export policy use pass-through behavior. Modules with
custom exports currently include:
- `current_conditions`: omits lower-case condition text and duplicate
wind-direction text.
- `hourly_forecast`: omits hour labels, lower-case description text, and the
template precipitation-mention helper while keeping forecast facts.
- `derived_daypart_summaries`: omits deterministic sentence-construction
helpers while keeping daypart period, condition, temperature trend,
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
The rich module snapshot and generated-template render context still contain
the helper fields used by deterministic Markdown templates.
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
use the same package schema and categories when converted into prompt input.
The default hourly module list places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under
`narrative_products`, and current/hourly data under `raw_data`. It does not
include civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data
package.
The default Daily, Today, and Tomorrow module lists include civil-day summary
stanzas, planning stanzas, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium. Daily uses
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
`tomorrow_planning`.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
- This package owns prompt package schema, YAML marshaling, YAML loading, and
validation.
- It does not collect weather data, derive forecast summaries, execute modules,
choose module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium.
## Config Fields Used
None directly. Config-derived values such as timezone, units, and prompt
location are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used
None.
## State Or Manifest Behavior
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`.
## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior
Validation fails before render preflight when required top-level fields are
missing or inconsistent, when the valid period is invalid, or when no module
stanzas are present. Save failures include filesystem operation and path
context.
## Tests
Inspect:
- `internal/promptinput/package_test.go`
- `internal/app/app_test.go`
## Invariants
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category.
- Data-package stanzas use curated module prompt exports when present and rich
values only as pass-through or fallback values.
- Data packages are narrower than generated-template render contexts.
- Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.
The package is narrower than a template render context and never infers changes
from report prose.

View File

@@ -0,0 +1,24 @@
# Promptkit Adapter Internals
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit.
The CLI maps `promptkit` configuration to a `PromptExecutorConfig` and constructs one executor
per action. Promptkit dependency types do not escape the adapter.
The adapter exposes exact prompt and profile inspection plus prepared execution. It maps Promptkit
inspection values to project-owned prompt input, output-contract, profile, preparation, execution,
validation, and optional debug values. It classifies adapter failures without copying provider secrets
or unbounded response bodies into application errors or normal state.
The app calls the executor's preparation callback before provider execution to persist safe preparation
provenance. Completed executions are then persisted as safe execution provenance and raw generated text
is validated by `internal/generatedtext`. The adapter does not write workspace state, render Markdown,
choose report definitions, or send Distributor notifications.
Focused tests:
```sh
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
```
The public logical prompt/profile/schema contract is owned by the
[Promptkit integration guide](../integrations/promptkit.md).

View File

@@ -1,143 +1,69 @@
# Report Registry Internals
This document describes report identity, valid-period resolution, output
naming, artifact grouping, batch command names, and comparison declarations in
`internal/report`.
`internal/report` owns the registry of report identities and the data declared
for each one: resolution, prompt identity and version, comparison policy,
artifact group, output-copy name, default module composition, and Distributor
path declarations. The public command syntax is owned by the
[CLI reference](../cli.md); configuration aliases and overrides are owned by
the [configuration reference](../config.md).
## Purpose
## Definitions and resolution
`internal/report` is the canonical source for report definitions, public
command names, config-key aliases, and batch command names. App, config, state,
module building, and CLI wiring consume report-owned helpers and resolved
definitions instead of owning report identity policy themselves.
Each `Definition` declares a stable ID and display name, prompt ID, generation
version, template and generated-text schema IDs, valid-period resolver,
comparison strategy, artifact group, batch-copy filename, Distributor path
templates, generation eligibility, compatible prior IDs, default modules, and
batch eligibility flags. `Resolved` combines that definition with the valid
period and run metadata for one invocation.
## Definition Fields
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
| --- | --- | --- | --- | --- | --- |
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` |
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `hourly.md` |
Each report definition declares:
Each report pairs its ID and prompt version with matching template and schema
IDs. Exact template fields and schema assets belong to [report templates](../templates.md)
and [generated-text internals](generatedtext.md).
- report ID and display name
- Scriptorium prompt ID
- generation mode
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- default ordered module composition
All valid periods are half-open.
Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`three-day`, `weekend`, and `storm`. Config keys also accept selected
underscore and descriptive aliases such as `three_day_outlook`,
`weekend_outlook`, and `storm_report`.
## Registry collaborators
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
independent Today report ID `today`. `reports.today` is not an alias for
`reports.daily`, and retired report keys are not supported.
`DefaultRegistry` is the only source of the four report definitions.
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
report identity rules. Registry overrides clone a definition and replace its
module list only after the report ID is recognized.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Daily Report,
Today Report, Tomorrow Report, and Hourly Report declare
`generated_text_template`; the app uses their template and schema identifiers
to validate generated text and render embedded Markdown templates.
The definition's `DistributorPathTemplates` are internal declarations consumed
by app orchestration. Their rendered external bundle paths and compatibility
contract are documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md), not repeated here.
## Reports
`morning` and `evening` are registry-owned batch names. Registry flags declare
fixed report eligibility; app orchestration determines data-dependent Daily
membership and produces the actual batch plan.
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | --- |
| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
## Module composition and failures
All report definitions are eligible for generation.
Each definition supplies an ordered `[]module.ConfigItem`; the complete
report-to-module mapping is maintained in [module internals](module.md).
`ArtifactGroup`, `BatchOutputName`, and comparison compatibility
are likewise consumed by state and orchestration rather than recomputed there.
## Valid Periods
Unknown report IDs or batch names return errors. The registry never collects
weather data, builds modules, parses CLI flags, writes state, executes
Promptkit, or delivers a report.
- Daily Report covers the selected local civil day and requires an explicit
date.
- Today Report covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window.
- Storm Report covers an explicit event window supplied by the caller.
## Verification and invariants
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.
Focused tests cover definition completeness, command and alias lookup, period
resolution, run IDs, path declarations, composition defaults, and override
validation:
## Boundaries
```sh
go test ./internal/report
```
`internal/report` defines report metadata, public report names, batch command
names, output naming, and time coverage. It does not collect weather data, plan
batch membership, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
The CLI parses flags and command structure, then uses report-owned helpers for
report and batch command names. Config loading uses report-owned helpers for
report override keys.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`.
## Batch Commands
`internal/report` owns the public batch command names `morning` and `evening`
and validates them through `BatchForCommandName`. Data-dependent batch
membership is owned by `internal/app`, because it depends on collected hourly
forecast coverage.
Report definitions still declare default batch output copy filenames. App
batch planning uses those filenames for fixed report entries and supplies
date-qualified names for dynamic Daily entries.
## State And App Usage
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- Module composition defaults use `Modules`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID.
## Failure Behavior
- Unknown report IDs and batch names return actionable errors.
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
- Storm Report resolution requires start and end, with end after start.
## Tests
Inspect:
- `internal/report/period_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Report selection goes through the registry.
- Public command names, config-key aliases, and batch command names are owned
by `internal/report`.
- Direct Markdown reports have empty template and generated-text schema IDs.
- Generated-text-template reports declare prompt, template, and schema IDs in
their report definition.
- Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy
are declared by report definition.
- App-owned batch planning uses report definitions but does not live in the
report registry.
All report selection goes through the registry, and the registry is the source
of truth for report identity—not rendered report text or app-local constants.

View File

@@ -1,125 +1,51 @@
# Report Template Internals
This document describes embedded Markdown templates and GeneratedText schemas
in `internal/reporttemplate`.
`internal/reporttemplate` embeds and renders the repository's native Markdown
templates. The current template IDs are `daily`, `today`, `tomorrow`, and
`hourly`. The template files, partials, and complete render-context field
reference are maintained in
[report templates](../templates.md).
## Purpose
## Assets and lookup
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
Tomorrow, and Hourly.
The package embeds top-level templates and shared partials. `Template` returns
the requested embedded template and fails with the requested ID when it is
unknown or unreadable.
The package embeds assets from:
Generated-text schemas and Promptkit definitions are owned by
`internal/promptassets`; report-template owns Markdown source only. Report
definitions select IDs, while [generated-text internals](generatedtext.md)
verifies the supported schema/template pairing.
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/templates/partials/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
## Rendering
Generated-text prompt source files live under
`internal/reporttemplate/prompts/`. They are repository assets for prompt
registration, not embedded lookup APIs.
`Render` loads the top-level template, creates a `text/template` with helper
functions and `missingkey=error`, parses the template, parses every shared
partial, and executes the result against the typed render context. This makes
missing context fields, bad template syntax, unreadable partials, and execution
failures actionable with template or partial context.
## Inputs And Outputs
Top-level templates decide which shared partials they invoke. The current
partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data
packages, and it must not reimplement weather selection or generated-text
validation.
Inputs:
## Boundaries and verification
- template ID from a report definition
- typed render context built by `internal/generatedtext`
This package does not collect weather data, build modules, validate generated
text, construct contexts, resolve report definitions, write state, execute
Promptkit, or upload reports. It produces Markdown bytes for application
orchestration to persist.
Outputs:
Focused tests cover template lookup, rendering, partial
behavior, missing keys, and malformed context:
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
```sh
go test ./internal/reporttemplate
```
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
`hourly`, backed by matching `*.generated_text.schema.json` files.
Generated-text prompt sources are maintained under
`internal/reporttemplate/prompts/`, including Daily's
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not collect weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration uses `internal/generatedtext` catalog lookup to connect
`internal/report` definition schema/template IDs to the matching validator,
render-context builder, and embedded assets.
## Template Contracts
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
- report metadata labels such as title, location, valid period, and generation
time
- validated GeneratedText prose slots
- deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
daypart forecast rows, daily/daypart summaries, planning facts, and a
multi-paragraph forecast discussion generated-text slot. The ordered daypart
slice is built in Go so templates do not range over maps.
The Daily template asset uses the same Markdown structure as Tomorrow's
template and renders from `generatedtext.DailyRenderContext`.
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls
`today_daypart_forecast` so it can omit elapsed or missing dayparts. Daily,
Today, Tomorrow, and Hourly call the shared `alert_digest` and
`precipitation_timing` partials. Partial files are parsed with each top-level
template at render time and receive the same typed render context as the
caller. The `alert_digest` partial renders the combined Alerts and Risk
Products section from relevant NWS alerts and curated SPC outlook digest
records.
## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected
to write for each generated-text prompt. Hourly requires:
- `summary`
- `forecast_discussion`
Daily, Today, and Tomorrow require `summary` and a nonempty
`forecast_discussion` array. All generated-text schemas allow optional
`precipitation_timing` and `confidence`, and reject additional properties.
Weather truth remains in module outputs; GeneratedText is limited to prose
slots consumed by the template.
## Failure Behavior
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Partial read or parse errors include the partial path.
- Template execution errors include the template ID and usually identify the
missing context field.
## Tests
Inspect:
- `internal/reporttemplate/reporttemplate_test.go`
- `internal/generatedtext/render_context_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Shared Markdown partials live under `templates/partials/`.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather
facts.
Embedded templates stay as separate files and shared fragments stay under the
partial directory. Generated-text schemas are embedded separately by
`internal/promptassets` and describe prose slots rather than deterministic
weather facts.

View File

@@ -1,110 +0,0 @@
# Scriptorium Adapter Internals
This document describes the subprocess adapter in
`internal/adapters/scriptorium`.
## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation or structured generated-text output. It isolates
subprocess execution, argv construction, timeout handling, output capture, and
exit-code interpretation from app and domain packages.
## Inputs And Outputs
Inputs:
- prompt ID
- YAML prompt input data package path
- report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
Outputs:
- argv used for execution
- captured stdout and stderr
- truncation flags for captured output
- exit code
- report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries
`internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input,
collect weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned
artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used
- `scriptorium.binary`
- `scriptorium.config_path`
- `scriptorium.profile`
- `scriptorium.timeout`
- `scriptorium.extra_args`
## Commands
Render preflight argv starts with:
```text
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
Report generation argv starts with:
```text
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render, Markdown run, and structured 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
- Missing prompt ID or data package path returns an error before subprocess
execution.
- Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method.
- Nonzero render, Markdown run, and structured run exits return the captured
result plus an error containing the exit code and stderr.
## Tests
Inspect:
- `internal/adapters/scriptorium/runner_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`.
- Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -1,178 +1,56 @@
# State Internals
This document describes filesystem state in `internal/state`.
`internal/state` owns safe workspace paths, atomic artifact writes, metadata,
prior-snapshot lookup, and read-only inspection. Operators should use the
[operations guide](../operations.md) for lifecycle and retention.
## Purpose
## Artifact Paths
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
For each run, paths are grouped by artifact group and valid start date:
## Inputs And Outputs
| Artifact | Location |
| --- | --- |
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
| Prompt preparation | `preflight/<group>/<date>/prompt_preparation.<run-id>.json` |
| Prompt execution | `snapshots/<group>/<date>/prompt_execution.<run-id>.json` |
| Raw generated text | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` |
| Validated generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
| Render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
| Notification | `notifications/<group>/<date>/distributor.<run-id>.json` |
Inputs:
Batch notification records are `notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
- workspace configuration
- resolved report definition and valid period
- module snapshot
- prompt input data package
- preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request
- RunID for inspection lookups
## Metadata And Debug Storage
Outputs:
New metadata is `weatherreporter.metadata.v2` and gains preparation and
execution paths only after those artifacts are saved. Legacy V1 records remain
readable; their historic preflight and generated-text-result fields are mapped
to the corresponding preparation and execution views during inspection. New
runs never write V1 records.
- module snapshot JSON path
- prompt input data package YAML path
- render preflight JSON path
- generated-text raw JSON path
- generated-text run-result JSON path
- validated generated-text JSON path
- render context JSON path
- managed Markdown report path
- metadata JSON path
- distributor notification debug artifact paths
- prior comparable snapshot metadata
- loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection
Prompt preparation and execution records are validated on both save and load.
They require exact report/prompt identity, complete timing, internally
consistent provenance, and status-appropriate validation or bounded classified
errors. Completed execution provenance keeps Promptkit's run identity distinct
from the Weatherreporter run identity.
## Boundaries
For a completed prompt run, the execution record is atomically replaced after
each downstream artifact is saved. Its path set therefore records the raw and
normalized generated text, render context, managed report, requested output
copy, and notification artifact actually reached without changing the original
Promptkit outcome.
`internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags.
`PromptDebugWriter` is separate from workspace state. An empty root disables
it. An enabled absolute root is checked for safe directories and symlinks, then
stores `preparation.json` and `execution.json` beneath
`<root>/<report-id>/<valid-date>/<run-id>/`. Directories are `0700`; files are
atomic `0600`. Normal state discovery does not read this root.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
converts adapter render results into that shape before saving.
Focused checks:
## Config Fields Used
- `workspace.root`
- `workspace.snapshots_dir`
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
- `workspace.notifications_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## Managed Layout
Paths are derived from the resolved report definition's artifact group, the
valid-period start date, and the RunID. Filenames put the artifact kind before
the RunID.
```text
<workspace.root>/
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
```sh
go test ./internal/state
```
Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and
configured prompt location. For generated-text-template reports, metadata also
records the generated text schema ID and links the raw generated text,
Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory.
Batch notification artifacts are stored under the notifications tree rather
than report metadata because they describe a batch-level upload. The date
directory is the batch start date in the effective report timezone.
## Prior Lookup
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.
- Daily Report compares with prior Daily Report snapshots for the same valid
local date.
- Today Report compares with prior Today Report snapshots for the same valid
local date.
- Tomorrow Report compares with prior Tomorrow Report snapshots 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.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Generated-text raw and
validated JSON artifacts are written atomically as bytes; generated-text run
result and render context artifacts are written atomically as JSON. Managed
Markdown reports are prepared by creating their parent directory; Scriptorium
writes the report body to the prepared path. Extra Markdown copies are handled
by app orchestration. Distributor notification debug artifacts are written
atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Single-report notification artifacts use schema version
`weatherreporter.distributor_notification.v1` and record one managed source
path plus that source's bundle paths. Batch notification artifacts use schema
version `weatherreporter.batch_distributor_notification.v1` and record:
- `batch`
- `batchRunId`
- `attemptedAt`
- `endpoint`
- `pipelineId`
- `bundleId`
- `idempotencyKey`
- `bundleCreated`
- `includedReports`, each with `reportId`, `runId`, `sourcePath`, and
`bundlePaths`
- `status`
- `upload`
- `runStatus`
- `statusError`
- `error`
Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather
than creating state.
## Failure Behavior
- Invalid workspace paths return validation errors.
- Missing required metadata fields prevent metadata writes.
- JSON writes use a temporary file followed by rename where practical.
- Read and decode failures include path context.
- Unknown RunIDs produce an actionable lookup error.
## Tests
Inspect:
- `internal/state/filesystem_test.go`
- `internal/app/app_test.go`
## Invariants
- Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata.
- Batch notification artifacts live under `notifications/batches` and are not
linked from report metadata.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -1,101 +1,69 @@
# Weather Data Internals
This document describes Weather API ingestion into `weatherdata.Bundle`.
`internal/weatherdata` owns the normalized, wire-independent weather bundle
that passes from collection through rendering and persistence. The Weather API
adapter translates provider responses into these types; its request, response,
and availability contract is documented in the
[Weather API integration guide](../integrations/weatherapi.md).
## Purpose
## Bundle contract
`internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API and assembles the bundle consumed by forecast derivation
and module builders. Module builders expose normalized current conditions and
weather story context when those sources are available.
`Bundle` has a collection timestamp (`FetchedAt`), source provenance
(`Sources`), and collection-level warnings (`Warnings`). Its product fields are
optional so an allowed missing source can be represented without manufacturing
weather data.
## Inputs And Outputs
| Field | Normalized product |
| --- | --- |
| `Observation` | Station observation |
| `Current` | Current conditions |
| `Hourly` | Hourly forecast periods |
| `Narrative` | Narrative forecast |
| `Alerts` | Active-alert check, including an explicitly empty result |
| `Discussion` | Forecast discussion and its time-range sections |
| `Daily` | Daily forecast periods when supplied |
| `WeatherStory` | Latest weather story |
| `SPCConvectiveOutlooks` | Convective outlook run, discussions, and GeoJSON geometry |
Inputs:
The bundle carries values rather than provider request details. Consumers use
it to construct report facts and data packages; they should not infer a
provider endpoint or retry policy from the normalized types. See
[collection](collect.md) for assembly and
[report templates](../templates.md) for the values exposed to authors.
- `config.Config` with Weather API URL, timeout, format, units, timezone,
precision, and missing-source policy
- HTTP responses using the Weather API `data` envelope
## Source provenance
Outputs:
Every checked source is represented by a `Source` entry. The record identifies
the source (`Name`), request location and query (`Endpoint`, `Query`), fetch
time, provider issue and update times when available, a SHA-256 digest of the
source data, and whether the source was unavailable (`Missing`). Its warnings
stay with that source in addition to the bundle-level warning list.
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers
An empty product can be meaningful checked data. For example, an explicit
empty alerts result is not missing and retains its source hash. A source is
marked missing only when the adapter's missing-source policy treats the
response or parsing failure as unavailable. The policy itself belongs to the
[configuration reference](../config.md).
## Boundaries
## Warning semantics
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
decoding into internal bundle types.
- It does not derive dayparts, resolve report periods, build module values, compare
snapshots, write report state, or invoke Scriptorium.
`SourceWarning` has a source name, stable code, severity, explanatory message,
endpoint, and `CompletenessImpact`. When collection proceeds with a warning,
the same warning appears in `Source.Warnings` and `Bundle.Warnings` so both
local provenance and whole-run consumers see it. A policy that treats a missing
source as an error returns no partial bundle.
## Config Fields Used
Warnings describe data completeness, not rendering or delivery failures.
Those failures are recorded by the application and state layers; see
[application orchestration](app-orchestration.md) and [state internals](state.md).
- `weather_api.base_url`
- `weather_api.timeout`
- `weather_api.format`
- `weather_api.units`
- `weather_api.timezone`
- `weather_api.precision`
- `missing_source.default`
- `missing_source.sources`
## Boundaries and verification
## External Adapters Used
This package defines data shapes and has no HTTP client, configuration loader,
filesystem access, or template behavior. Focused tests cover the normalized
types and the Weather API adapter verifies translation into them:
- Weather API HTTP service
See [Weather API integration](../integrations/weatherapi.md) for the external
contract used by this project.
## State Or Manifest Behavior
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted,
warned, or treated as errors according to missing-source policy. Hourly forecast
data is required and cannot be skipped.
## Failure Behavior
- Missing or invalid `weather_api.base_url` prevents client construction.
- HTTP errors, response read failures, and envelope decode failures include
endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests
Inspect:
- `internal/adapters/weatherapi/client_test.go`
- `internal/app/app_test.go`
## Invariants
- Weather facts come from normalized source data.
- Full hourly and narrative products are fetched; Go owns report-period
selection.
- Source provenance and warnings remain inspectable downstream.
```sh
go test ./internal/weatherdata
go test ./internal/adapters/weatherapi
```

View File

@@ -1,77 +1,69 @@
# Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery,
and operational caveats. For symptom-specific diagnosis, see
This guide covers normal operation, managed workspace state, inspection,
recovery, and operational caveats. See the [CLI reference](cli.md) for complete
command syntax and the [configuration reference](config.md) for fields,
defaults, and notification templates. For symptom-based diagnosis, see
[Troubleshooting](troubleshooting.md).
## Normal Workflow
## Normal Operation
Generation commands:
After configuring a Weather API endpoint, generate one report:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate today
weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```sh
weatherreporter generate today --out ./today.md
```
Generation commands resolve a report period, collect a Weather API bundle,
build a rich JSON module snapshot, build a curated YAML prompt input data
package, run `scriptorium render`, and write managed artifacts under the
configured workspace. Markdown-path reports then run `scriptorium run` directly
to the managed Markdown report path.
A generation collects weather data, resolves the report period, builds and
persists the module snapshot and prompt data package, records Promptkit
preparation provenance before provider execution, then persists raw output and
execution provenance, validates the structured generated text, and renders the
managed Markdown report from the validated text and deterministic values.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run`
to raw GeneratedText JSON, validate the structured text, save a render context,
and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not included in `run morning` or
`run evening`.
The managed report and its final metadata are saved before single-report
Distributor notification is attempted. `--out` writes an extra operator copy;
it never changes the managed report or upload source. A successful generate
command prints its summary to stdout unless `--quiet` is used.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source. Generate commands emit a compact JSON summary
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
or other schedulers that only need nonzero exits and external logs.
## Optional Prompt Debug Capture
Batch commands:
Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
```text
weatherreporter run morning
weatherreporter run evening
```sh
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
```
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
for each later future local civil day with complete hourly forecast coverage.
`run evening` generates Tomorrow Report and the same eligible future Daily
reports. Future Daily expansion starts with the day after tomorrow. A Daily
report is eligible only when the collected hourly forecast contains every
hourly period for that local civil day; partial days are skipped. Batch commands
collect weather data once before planning, and a collection failure stops the
batch before any report is generated.
The directory must be absolute and is initialized before prompt inspection or
weather collection. Capture files are stored outside the managed workspace,
with restrictive permissions, under the report ID, valid date, and RunID.
They can contain rendered prompts and generated output, so the normal metadata,
CLI summary, and routine logs contain only the optional directory path—not
their content. A capture-write failure stops that run before later work can
continue.
After planning succeeds, batch commands print a JSON summary to stdout, write
compact per-report status lines to stderr, continue independent reports after
one report fails, and return nonzero when any report failed. Batch commands do
not upload each report independently. When distributor notification and batch
notification are enabled, weatherreporter uploads one distributor bundle only
after every planned report succeeds. If any report fails, the batch upload is
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
distributor upload sources. Use `--quiet` to suppress successful batch summary
and status output; failures still return nonzero.
Run a scheduled batch with the same configured collection:
## Filesystem Layout
```sh
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
```
The default workspace root is `workspace`.
Each batch validates its configured prompt/profile candidates, then collects once before it plans reports. Morning runs Today, Tomorrow,
and every eligible dated Daily Report; evening runs Tomorrow and the same
eligible Daily Reports. Eligible Daily dates begin after tomorrow and require
complete hourly coverage for their entire local civil day. A batch continues
after an individual report fails and returns an aggregate failure when any
report or batch notification fails.
`--out-dir` writes extra copies such as `today.md`, `tomorrow.md`, and
`daily-YYYY-MM-DD.md`. These copies are never upload sources. Batch report
copies and notification behavior are summarized in the CLI result; use the
[CLI reference](cli.md) for its exact JSON and stderr contract.
## Managed Workspace
The default workspace root is `workspace`. Artifact paths use the report
definition's artifact group, the valid-period start date in the effective
timezone, and the RunID:
```text
workspace/
@@ -80,206 +72,107 @@ workspace/
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/prompt_execution.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
preflight/<artifact_group>/<YYYY-MM-DD>/prompt_preparation.<run_id>.json
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
```
Managed artifact filenames use the artifact kind and RunID, so repeated runs
for the same valid period do not overwrite each other. The date directory is
the valid-period start date in the effective report timezone. Generated-text
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
The generated-text and render-context artifacts are written for every completed
single-report generation.
A report's metadata links the module snapshot, data package, preparation and
execution receipts, managed report, generated-text artifacts, and any available single-report
notification artifact. Batch notification artifacts are separate batch-level
records under `notifications/batches`.
## RunID And Metadata
RunIDs are based on generation time plus report ID. Reports that can be
generated more than once in a single command may append a report-specific
disambiguator. Daily appends the local valid date so multiple dynamic Daily
reports in one batch have distinct managed artifacts:
```text
20260529T100000.123456789Z_daily_2026-05-31
20260529T100000.123456789Z_today
```
Batch notification RunIDs use the batch start timestamp plus the batch command
name:
```text
20260529T100000.123456789Z_morning
20260529T220000.123456789Z_evening
```
Each generated report writes metadata that links:
- RunID, report ID, variant, and prompt ID
- generation time, timezone, and valid period
- source location, source hashes, and source warnings
- module snapshot path
- prompt input data package path
- preflight output path
- managed Markdown report path
- generated text schema ID and generated-text artifact paths for
generated-text-template reports
- distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, valid
period, and known artifact paths for each attempted report. Single-report
notification fields on report items are empty for batch commands. When a batch
notification is attempted, skipped, or fails, the summary includes one
top-level `notification` object with fields such as `status`, `reason`,
`runId`, `pipelineId`, `bundleId`, `idempotencyKey`, `path`,
`includedReports`, and `error`.
RunIDs begin with the UTC generation timestamp and report ID. A Daily RunID
also contains its local valid date so multiple Daily reports in one batch have
different managed paths. Batch notification RunIDs contain the UTC batch start
timestamp and batch name.
## Distributor Notification
Distributor notification is configured with `notify.distributor` and is
disabled by default. For `generate <report>`, weatherreporter uploads the
managed Markdown report path recorded in the report result and metadata. That
single source file is mapped to report-specific bundle paths. Extra copies
written by `--out` or `--out-dir` are operator conveniences only.
When `notify.distributor.enabled` is enabled, a successful `generate`
uploads only the managed Markdown report after final metadata has been saved.
The extra copy from `--out` is never uploaded. A notification attempt writes
a redacted debug artifact at
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`; its
path is then recorded in report metadata.
For `run morning` and `run evening`, per-report notification is suppressed. If
`notify.distributor.enabled` and `notify.distributor.batch.enabled` are both
true, the batch uploads once after all reports finish successfully. The upload
contains one file mapping set per included report. Each mapping uses the
managed Markdown report as the source and report-specific path templates for
that report. All rendered bundle paths across the batch must be unique. If any
report fails, weatherreporter records a top-level
notification status of `skipped` with reason `one or more reports failed` and
does not call distributor. If batch notification is disabled, run commands do
not fall back to per-report uploads.
Batches suppress per-report notification. When both Distributor and its batch
notification are enabled, Weatherreporter submits one multi-report upload after
every planned report succeeds. If any report fails, it records a top-level
`skipped` notification with reason `one or more reports failed` and does not
call Distributor. If batch notification is disabled, a batch does not fall back
to individual uploads.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The default bundle ID is a stable logical source identity derived from
producer name, location ID, and report ID:
A batch notification attempt writes
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`.
A notification failure makes the batch fail but does not change successful
individual report items into failed items. The debug artifacts contain rendered
identifiers, managed source and bundle paths, upload and status results, and
redacted errors; they do not contain tokens.
```text
weatherreporter.{location_id}.{report_id}
```
## Inspecting Stored Runs
The default single-report idempotency key appends RunID to the rendered bundle
ID so each report generation has a distinct retry identity. The default bundle
path uses the valid-period start date, artifact group, and RunID. Batch bundle
IDs default to `weatherreporter.{location_id}.{batch}`, and batch idempotency
keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
merge, retention, and derived snapshot behavior such as `latest`. For Daily,
the default report ID and artifact group values are both `daily`, and the
default output filename value is `daily.md`. For Today, the default report ID
and artifact group values are both `today`, and the batch output filename value
is `today.md`.
Inspection is read-only: it neither collects weather data nor invokes
Promptkit or Distributor. Start by finding a RunID:
Single-report notification happens after final metadata save for generated
reports. Batch notification happens after all planned reports have finished and
only when all report generations succeeded. Collection, module snapshot,
data-package, render preflight, Scriptorium run, generated-text validation,
template rendering, and metadata-save failures do not trigger notification. A
single-report notification failure fails that report. A batch notification
failure makes the batch return nonzero and increments the aggregate failure
count, but individual report items remain succeeded.
Each notification attempt writes a debug artifact under `notifications/`.
Single-report artifacts live under
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
artifacts live under
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
where the date directory is the batch start date in the effective report
timezone. The artifact records the rendered pipeline ID, bundle ID,
idempotency key, managed source paths, bundle-relative paths, bundle created
timestamp, accepted upload response, and the latest distributor run status
response when available. Weatherreporter polls status until distributor reports
`succeeded` or `failed`, or until the configured notification timeout expires.
The run status includes the distributor status, error text, and raw run report
JSON, which can show actions such as `replace_older`, `skip_same`,
`skip_destination_newer`, or `failed`. Token values are not written.
Weatherreporter is responsible for selecting the managed Markdown report,
constructing a source bundle, and submitting it to the configured distributor
HTTP endpoint. Distributor remains responsible for destination routing,
publication, and any downstream Markdown-to-HTML transformation. Distributor
leaves destination files alone when they are not tracked by a newly uploaded
bundle, so existing uploaded dated report paths can remain available.
## Inspection
Inspection commands read existing workspace artifacts and emit JSON to stdout.
They do not collect weather data or run `scriptorium`.
```text
```sh
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot with rich
template-facing values, and `inspect data-package` to review the curated prompt
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
snapshot selected for Recent Changes, or `null` when none exists. Use
`inspect sources` to review source provenance and warnings without dumping full
weather payloads.
| Command | Reads |
| --- | --- |
| `inspect reports` | Metadata files under the workspace snapshots tree. |
| `inspect metadata RUN_ID` | Metadata located by RunID. |
| `inspect modules RUN_ID` | The module snapshot path recorded in metadata. |
| `inspect data-package RUN_ID` | The data-package path recorded in metadata. |
| `inspect prior RUN_ID` | The run metadata, then compatible earlier metadata for its comparison policy. |
| `inspect sources RUN_ID` | Source provenance and warnings in the run metadata. |
## Recent Changes
A missing snapshots directory produces no listed reports. An unknown or empty
RunID is an error; use `inspect reports` to obtain a valid value.
Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text.
Daily Report compares with prior Daily Report snapshots for the same valid
local date. Today Report compares with prior Today Report snapshots for the
same valid local date. Tomorrow Report compares with prior Tomorrow Report
snapshots for the same valid local date. 3-Day Outlook compares with prior
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
compares with prior compatible Weekend snapshots for the same weekend window.
Hourly Report and Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty.
New runs write `weatherreporter.metadata.v2`, including preparation and
execution references once those receipts exist. `inspect metadata` also reads
historic V1 records; their legacy preflight and generated-text-result fields
remain visible for compatibility, but Weatherreporter does not write them for
new runs.
## Recovery
A failed generation run may still leave useful artifacts:
Keep the workspace when a run fails: artifacts reached before the failure
remain available where they can be safely persisted.
- If `scriptorium render` returns a result with a nonzero exit code, the
preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available.
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written.
- If single-report distributor notification fails, report artifacts and final
metadata remain available, but the report command returns nonzero.
- If batch distributor notification fails, report artifacts and final metadata
remain available, the top-level batch notification links the debug artifact,
and the batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the
artifact paths for each failed report or the top-level notification path.
- A preparation failure can leave its classified receipt and metadata.
- A report-generation failure can leave the managed report, module snapshot,
data package, and metadata.
- A completed prompt validation rejection leaves raw text, an execution receipt,
and metadata. Later generated-text failures can also leave validated text and
a render-context artifact, depending on where they stopped.
- A single-report notification failure preserves the report and final metadata,
including its notification artifact when it was written.
- A batch notification failure preserves each report's artifacts and adds the
top-level batch notification artifact.
For a bad report, start with:
```text
weatherreporter inspect metadata RUN_ID
weatherreporter inspect sources RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
```
Use the RunID from the action summary with the inspection commands above. For
a batch failure, inspect the summary first, then inspect the affected report
RunIDs or the batch notification path. Do not remove the whole workspace as a
first response; retain it until the failure is understood.
## Operational Caveats
- The application uses one configured Weather API endpoint.
- The application writes local filesystem state only.
- The application does not implement resume, cleanup, archive, remote storage,
daemon operation, or automatic storm monitoring.
- Generated reports and Scriptorium stderr can contain sensitive operational
context. Store workspace artifacts with appropriate filesystem permissions.
- Workspace files and generated reports can contain
sensitive operational context. Set appropriate filesystem permissions and do
not publish them unintentionally.
- Weatherreporter uses one configured Weather API endpoint and local workspace
state.
- It does not provide automatic resume, cleanup, archival, remote state, daemon
operation, or automatic storm monitoring.

View File

@@ -1,125 +1,73 @@
# Architecture
# Architecture Policy
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves.
## Purpose
## weatherreporter
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
This policy defines Weatherreporter's system shape, ownership, dependency direction,
and safety invariants. The [development guide](../development.md) owns the
package inventory; focused documents in `docs/internal/` own implementation detail.
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
## System Shape
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
Weatherreporter is a deterministic weather-report CLI. It collects normalized
weather data, derives facts and modules, builds a curated YAML data package,
compares prior snapshots, executes exact-version Promptkit prompts, validates
structured generated prose, and renders repository-owned Markdown. Completed
managed Markdown may be uploaded through Distributor.
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
collects once, validates its complete candidate prompt/profile set before
collection, then executes planned reports sequentially with one executor. It
continues after independent report failures and sends a batch notification only
after every planned report succeeds.
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
## Ownership And Boundaries
`distributor` is also an external adapter. Upload behavior must be isolated
under `internal/adapters/distributor`, dependency types from the distributor
module must not leak outside that adapter, and the selected upload source must
be the managed Markdown report rather than optional output copies or broad
workspace scans.
- `internal/cli` owns command parsing, help, summaries, and one executor
construction per action.
- `internal/config` owns defaults, loading, validation, and secret loading.
- `internal/app` owns workflow order, partial results, and notification
coordination through project-owned contracts.
- Deterministic domain packages own weather derivation, report periods, modules,
generated-text validation, and template contexts.
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
`internal/adapters/distributor` own their external dependency mechanics.
- `internal/state` owns workspace paths, V2 metadata, atomic persistence, and
read-only inspection.
## Project Shape
Dependency-specific Promptkit types remain inside its adapter. The application
does not parse flags, construct provider clients, or render provider output
directly.
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
## Prompt Execution Invariants
Business/domain logic should live outside CLI, transport, and external-adapter packages.
- Prompts receive curated module packages, never unbounded raw weather payloads.
- Every execution inspects the exact prompt version and output contract before
collection. The selected profile is configured explicitly or declared by the
prompt; unsupported direct-key profiles and missing reported credentials fail
before collection.
- Prepared execution persists safe preparation provenance before provider work.
Completed execution persists safe execution provenance; raw output is
validated before template rendering.
- Generated text fills defined prose slots only. Deterministic facts remain
authoritative and repository-owned templates produce all managed Markdown.
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
credentials never enter normal metadata, summaries, logs, or workspace
artifacts. They are written only to an explicit secure debug root when
requested.
## Dependency Policy
## State, Notification, And Testing Invariants
Prefer the Go standard library where practical.
- Managed writes are atomic where practical and stay beneath the configured
workspace root. Reached artifacts remain inspectable after later failures.
- New records use `weatherreporter.metadata.v2`; V1 records remain readable for
inspection compatibility.
- Distributor uploads use only the managed Markdown report, never output copies
or workspace scans. Notification follows report and final metadata success.
- Default tests are deterministic, offline, and use Promptkit/provider fakes
rather than live provider calls. See the [testing policy](testing.md).
Use external dependencies only when justified by correctness, security, interoperability, or substantial complexity reduction. Good reasons include complex security-sensitive behavior, such as HTML sanitization, or widely used de facto standards, such as YAML parsing.
## Non-Goals
Avoid dependencies for small conveniences. Do not let external dependency types leak across internal package boundaries unless the dependency is itself the explicit public contract of that package.
## Package Layout
Use this layout unless the project has a documented reason to differ:
- `internal/app`: application orchestration and top-level use cases.
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
- `internal/config`: configuration structs, defaults, loading, precedence, and validation.
- `internal/adapters/<name>`: adapters for external CLIs, APIs, databases, object stores, or libraries.
- `internal/api`: HTTP API handlers and request/response types, when the application exposes an HTTP API.
- `internal/transport/http`: HTTP client code, when the application calls HTTP services.
Package-private implementation constants may live near the package that owns them, preferably in `constants.go` when useful.
## Configuration
Centralize configuration loading, processing, precedence, defaults, and validation in `internal/config`.
The goal is to make configuration discoverable and avoid implicit or hidden operational values. User-visible defaults and cross-package operational defaults should be defined in `internal/config/defaults.go`.
Configuration precedence is:
1. CLI flags
2. configuration file
3. built-in defaults
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
## Adapters and External Integrations
Use a hexagonal architecture style for external integrations.
External adapters belong under `internal/adapters/<name>`. If an adapter uses an external dependency, that dependencys interface must not leak outside the adapter package. Other packages should interact only with the adapters API, so the dependency can be swapped, upgraded, or removed without touching unrelated code.
Adapters should be thin. Domain decisions belong in application/domain packages, not inside adapter glue.
## Components and Registries
When the application has major workflow components, each component should live
near the package that owns its contract and have explicit inputs and outputs.
The orchestrator should compose components in an explicit order using a default
sequence, dependency graph, or documented orchestration rule.
If users can select components, validators, renderers, or adapters, selection
should go through a registry or equivalent mechanism rather than scattered
conditionals.
## Embedded Assets
Store embedded JSON schemas, Markdown prompts, templates, and similar assets as separate files, not inline string literals, unless there is a strong reason otherwise.
## Errors and Logging
Errors should be actionable and preserve context. Wrap errors with operation and path/resource context. CLI code should convert internal errors into concise user-facing messages.
Errors and logs must not expose secrets.
Use structured logging where practical. Logs should describe operations, paths, external calls, retries, and failure causes, but should not include large user data by default.
## Context, Timeouts, and Cancellation
Long-running operations should accept `context.Context`. External calls,
subprocesses, HTTP requests, storage operations, and multi-step workflows should
respect cancellation and timeouts.
## State, Files, and Safety
If the application writes durable state, writes should be atomic where
practical. Multi-step workflows should preserve enough state to support
inspection and retry diagnosis after failure.
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
## Testing
Core logic should be testable without real external services. Use fakes, fixtures, or local test doubles for adapters where practical.
Config examples should be load-tested. Important CLI workflows should have
parser or command tests. Component contracts should have focused tests that do
not require running the full application unless end-to-end coverage is
intentional.
## Documentation
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
When changing architecture, config, CLI behavior, adapters, or component
contracts, update the relevant docs and examples in the same change.
Weatherreporter is not a weather-data ingestion service, general LLM
orchestration framework, plugin platform, HTTP service, multi-user job system,
or a replacement for Promptkit or Distributor.

View File

@@ -1,208 +0,0 @@
# Development Policy
This document is the contributor workflow policy for `weatherreporter`.
Developers and LLM coding agents should use it with
`docs/policy/architecture.md` and `docs/policy/documentation.md`.
## Repository Layout
- `cmd/weatherreporter`: binary entry point.
- `internal/app`: orchestration for generation, batches, fetch helpers, and
inspection.
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
- `internal/config`: configuration structs, defaults, loading, overrides, and
validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/weatherdata`: normalized weather source facts, source metadata, and
source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/facts`: collected and derived report fact contracts.
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
- `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations.
- `internal/briefing`: prompt-facing module value builders and module registry.
- `internal/changes`: structured Recent Changes comparison.
- `internal/promptinput`: Scriptorium `data_package` construction and
validation.
- `internal/state`: filesystem paths, atomic JSON writes, metadata, lookup, and
inspection support.
- `internal/timeutil`: clock, date, timezone, and period helpers.
- `docs`: user, operator, developer, integration, internal, policy, and roadmap
documentation.
- `examples`: maintained copyable examples.
## Local Validation
Use focused checks while editing and broader checks before committing:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Useful focused checks:
```bash
go test ./internal/cli ./internal/config
go test ./internal/app ./internal/state
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
```
Run `gofmt -w` on changed Go files before committing.
## Coding Conventions
- Keep domain logic out of `cmd`, `internal/cli`, and adapter packages.
- Prefer small explicit structs and functions over broad framework-style
abstractions.
- Keep package APIs narrow and named around implemented behavior.
- Return errors with operation, path, endpoint, report, or RunID context.
- Do not log or expose secrets.
- Use `context.Context` for external calls, subprocesses, and orchestrated
workflows that may be canceled.
- Use atomic writes for durable JSON artifacts where practical.
- Keep report selection and prompt IDs centralized in `internal/report`.
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
- Keep distributor package types and upload-client construction inside
`internal/adapters/distributor`.
- Keep Weather API transport and envelope handling inside
`internal/adapters/weatherapi`.
## Dependency Policy
Prefer the Go standard library. Add dependencies only when they materially
improve correctness, interoperability, security, or maintainability.
Current external dependencies:
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
construction and HTTP upload client behavior.
- `gopkg.in/yaml.v3` for YAML configuration parsing.
When adding a dependency:
- explain why the standard library is not enough;
- keep dependency types from leaking across unrelated package boundaries;
- add tests for the behavior the dependency supports;
- update this policy if the dependency becomes part of contributor workflow.
## Configuration Changes
Configuration is owned by `internal/config`.
When adding or changing a field:
- update `Config` and the nested config struct in `config.go`;
- add or adjust defaults in `defaults.go` when the field has a safe default;
- update loading or CLI override behavior in `load.go` only when needed;
- validate required values and accepted ranges in `validate.go`;
- add or update config tests;
- update `docs/config.md` and maintained examples when the field is user
visible;
- keep secrets out of example config files.
Configuration precedence is:
1. CLI overrides supported by `config.LoadOptions`;
2. configuration file values;
3. built-in defaults.
The default config path is `/usr/local/etc/weatherreporter/config.yml`.
## CLI Changes
The CLI is owned by `internal/cli`.
When adding or changing a command or flag:
- update help text and parser behavior together;
- declare whether the command is an action command or an inspection/data-output
command;
- convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages;
- use the centralized output helpers in `internal/cli/output.go`;
- keep action-command summary conversion in `internal/cli/result.go`;
- add parser or command tests in `internal/cli`;
- update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
operators.
CLI commands should return concise actionable errors and avoid printing partial
JSON when command construction fails.
## Components And Adapters
Use existing package boundaries before adding a package.
Add a new internal component only when it owns a distinct implemented contract.
Define its inputs, outputs, state behavior, failure behavior, tests, and
invariants in `docs/internal/`.
Adapters should stay thin:
- HTTP adapters own transport, request construction, envelope handling, and
decode boundaries.
- subprocess adapters own argv construction, timeout handling, stdout/stderr
capture, and exit-code interpretation.
- adapter packages should not own report selection, forecast summarization,
Recent Changes, or prompt input schema decisions.
When an external contract changes, update the matching file under
`docs/integrations/`.
## Tests
Core tests must not require live Weather API, Scriptorium, or distributor
services.
Preferred test patterns:
- fake command runners for subprocess behavior;
- `httptest.Server` for Weather API behavior;
- fake distributor upload clients for notification behavior;
- filesystem temp directories for state behavior;
- deterministic clocks for report periods and RunIDs;
- table tests for config validation, CLI parsing, period resolution, and
threshold behavior.
Add focused tests near the package that owns the behavior. Use app-level tests
for workflow ordering, persistence, and cross-package contracts.
## Examples
Examples under `examples/` must be real, maintained, and free of secrets.
When updating examples:
- use implemented config fields only;
- avoid private endpoints and credentials;
- keep comments short and operationally useful;
- add or update validation coverage when a new example file is introduced;
- link maintained examples from `docs/config.md`.
Do not add generated report examples unless they can be kept current without
live external services.
## Documentation Checklist
Documentation updates are part of behavior changes.
Update:
- `README.md` for project orientation or quickstart changes;
- `docs/cli.md` for command and flag changes;
- `docs/config.md` for config fields, defaults, and precedence changes;
- `docs/operations.md` for state, artifact, batch, inspection, and recovery
behavior;
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
- `docs/internal/` for component contracts and invariants;
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
contract changes;
- `docs/roadmap/` only for unimplemented or deferred work.
Non-roadmap docs must describe implemented behavior only.

View File

@@ -1,356 +1,232 @@
# Go Project Documentation Policy
# Documentation Policy
## Purpose
Project documentation must help four audiences:
1. users who need to run the application;
2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
This policy assigns each Weatherreporter documentation topic to one canonical
owner. Its goal is to keep documentation accurate, concise, discoverable, and
resistant to drift for users, operators, developers, integrators, maintainers,
and coding agents.
## Core Rules
### 1. Keep docs concise
Each document should cover a defined scope and only the essentials for that scope.
Avoid:
- long background explanations;
- repeated reference material;
- implementation detail in user-facing docs;
- aspirational language outside roadmap docs;
- verbose examples where one minimal example is clearer.
### 2. Document only implemented behavior outside roadmap files
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
- `docs/roadmap/`
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
### 3. Use canonical homes
Each type of information should have one canonical location.
Canonical homes:
- project purpose and quickstart: `README.md`
- development principles: `docs/policy/architecture.md`
- configuration reference: `docs/config.md`
- CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md`
- implemented internals: `docs/internal/`
- future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/`
Other files should summarize briefly and link to the canonical source.
### 4. Keep examples real
Examples should be valid, maintained, and free of secrets.
Where practical:
- example configs should load successfully;
- example commands should match real CLI syntax;
- important examples should be covered by tests.
## Documentation Profiles
All projects require:
- `README.md`
- `docs/policy/architecture.md`
Additional docs depend on the project.
### Small library
Recommended:
- `docs/policy/development.md`, if contributor conventions are non-obvious
### Simple CLI
Required:
- `docs/cli.md`
Recommended:
- `docs/policy/development.md`
### Config-driven CLI
Required:
- `docs/cli.md`
- `docs/config.md`
Recommended:
- `examples/`
- `docs/policy/development.md`
### Stateful or operator-facing application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
Recommended:
- `docs/troubleshooting.md`
- `examples/`
- `docs/policy/development.md`
### Modular, staged, service-oriented, or orchestration application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- validated examples under `examples/`
## Required Documents
### README.md
**Audience:** users, administrators, operators
The README is the outward-facing project orientation page.
It should include, in order:
1. concise description;
2. elevator pitch;
3. shortest useful command or usage example;
4. links to targeted docs.
The README should be short. It is not a manual.
The “shortest useful command” means the simplest command that performs the projects core use case. (It does not mean `app --help`.)
### docs/policy/architecture.md
**Audience:** developers, LLM coding agents
`docs/policy/architecture.md` is required for every project.
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
It should include:
- project shape;
- core design principles;
- package and boundary philosophy;
- state/persistence philosophy, if applicable;
- external integration philosophy, if applicable;
- error-handling and logging principles;
- testing expectations;
- documentation expectations;
- architectural invariants;
- explicit non-goals, if useful.
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
### docs/policy/development.md
**Audience:** developers, LLM coding agents
Required for projects maintained by humans and LLM coding agents.
It should include:
- repository layout;
- build/test commands;
- coding conventions;
- dependency policy;
- how to add config fields;
- how to add CLI flags;
- how to add stages/modules/adapters, if applicable;
- how to update examples;
- documentation update expectations.
### docs/config.md
**Audience:** administrators, operators, advanced users
Required for applications with configuration files.
It should include, in order:
1. config file locations and discovery precedence;
2. minimal working config;
3. production-oriented config;
4. full configuration reference;
5. secrets handling, if applicable;
6. links to maintained examples.
The full configuration reference should be canonical.
### docs/cli.md
**Audience:** users, administrators, operators
Required for CLI applications.
It should include, in order:
1. shortest useful command;
2. command overview;
3. complete flag reference;
4. common workflows;
5. diagnostic or recovery commands, if applicable.
Explain when commands are useful, not just their syntax.
### docs/operations.md
**Audience:** administrators, operators
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
It should cover:
- normal workflow;
- filesystem layout;
- remote storage layout, if applicable;
- logs and manifests;
- resume/retry behavior;
- cleanup behavior;
- archive/backup behavior;
- safe recovery procedures;
- operational caveats.
### docs/troubleshooting.md
**Audience:** administrators, operators
Recommended once recurring failure modes exist.
Each entry should include:
- symptom;
- likely cause;
- diagnostic command or inspection step;
- safe fix;
- relevant links.
### docs/internal/
**Audience:** developers, LLM coding agents
Required for modular, staged, service-oriented, or orchestration projects.
This directory describes implemented internal components. It is not the roadmap.
Use one file per major component where useful.
Each component doc should include:
1. purpose;
2. inputs and outputs;
3. boundaries;
4. config fields used;
5. external adapters used;
6. state or manifest behavior, if applicable;
7. skip/resume behavior, if applicable;
8. failure behavior;
9. tests to inspect before changing;
10. architectural invariants.
### docs/roadmap/
**Audience:** maintainers, developers, LLM coding agents
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
Roadmap docs should clearly distinguish:
- proposed work;
- accepted plans;
- deferred ideas;
- rejected ideas;
- implementation prompts or task breakdowns, if useful.
Roadmap docs should not be confused with current behavior.
### docs/integrations/
**Audience:** developers, LLM coding agents
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
Use one file per integration where useful.
## Examples Directory
Projects with non-trivial configuration or workflows should include `examples/`.
Useful examples include:
- minimal working config;
- production-oriented config;
- full annotated config;
- local development config;
- remote/object-storage config;
- minimal session/input file.
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
## Security and Privacy
Docs and examples must not include:
- real API keys;
- tokens;
- passwords;
- private keys;
- private environment dumps;
- sensitive user data;
- raw private transcripts;
- private infrastructure details unless intentionally public.
Document secret-handling mechanisms, not actual secret values.
## Maintenance Rules
When docs change, verify the affected behavior.
Where practical:
- load example config files in tests;
- test CLI examples or command parser behavior;
- validate documented flags against real flags;
- remove stale references;
- update links after renames;
- keep roadmap content out of non-roadmap docs.
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
Documentation is complete only when it matches the current code.
## Documentation Change Checklist
Before merging documentation changes, verify:
- README is concise and orientation-focused.
- `docs/policy/architecture.md` describes development principles.
- Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals.
- Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema.
- CLI examples match real commands and flags.
- Defaults appear in the canonical config reference.
- No secrets or private data are included.
- Links are accurate.
### One Canonical Documentation Owner
Each authoritative fact belongs in one canonical document or documentation
area. A non-owning document may give a short, stable summary for orientation,
but it must link to the canonical owner instead of maintaining a second
definition.
Volatile details include commands, flags, configuration fields and defaults,
report and module IDs, schemas, file names, paths, status and exit behavior,
retry behavior, and runtime guarantees. If readers could reasonably treat a
statement as a contract, its exact documentation belongs with the owner named
in this policy.
Executable sources of truth and documentation owners serve different purposes.
Code, schemas, and embedded assets determine runtime behavior. The canonical
document owns the corresponding explanation or reference for readers. Both may
necessarily express the same contract, but other documentation should summarize
and link rather than create another complete reference. When implementation and
documentation disagree, verify the intended behavior and update them together.
### Current State, Decisions, And Future Work
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
An accepted architecture decision may describe an approved direction before it
is implemented, but acceptance is not evidence that the behavior exists.
Current-state documents change when the implementation lands. Temporary
roadmaps own future work, sequencing, and implementation status; they do not
replace durable policies, decisions, or current contracts.
### Audience And Detail
Write for the document's stated audience and include only the detail needed for
its owned topic. User and operator documentation should not expose incidental
implementation detail. Developer documentation should link to user-facing and
external contracts instead of restating them.
### Links
Use descriptive link text and repository-relative links for repository
documents. Link to the canonical owner rather than to a duplicate summary.
Check every added or changed link, and repair or remove links when their target
moves or is retired.
### Examples And Code Fences
Complete copyable files belong in `examples/` when maintained examples exist.
Documentation may use the smallest illustrative snippet needed for its owned
topic, but should link to a maintained example instead of embedding a second
complete copy.
Examples must be valid, secret-free, and tested where practical. Commands,
flags, configuration, imports, and Go snippets must match implemented behavior.
Use a language tag on fenced code blocks, and identify fragments that are
illustrative rather than directly runnable.
### Security And Privacy
Documentation and examples must not contain real credentials, private keys,
private environment dumps, sensitive source material, or private
infrastructure details unless intentionally public. Document secret-handling
mechanisms, not secret values.
## Canonical Ownership
| Topic | Canonical owner | Owned content | Content owned elsewhere |
| --- | --- | --- | --- |
| Product orientation and minimal quickstart | `README.md` | What Weatherreporter is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, architecture, and implementation detail. |
| Contributor workflow and package inventory | `docs/development.md` | Repository layout, local workflow, validation commands, coding conventions, task-specific change guidance, dependency workflow, and repository hygiene. | Architectural invariants, user-facing contracts, detailed subsystem behavior, and future work. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, safety properties, and non-goals. | Concrete implementation mechanics, contributor procedures, decision history, and future work. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Application architecture and runtime behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, stable test boundaries, doubles, coverage guidance, regression policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| Release procedure | `docs/release.md` | Version policy, release preparation, validation, tagging, automated publication, verification, failure handling, and release ordering. | General contributor workflow, product contracts, release-specific change summaries, and implementation history. |
| Release notes | `docs/releases/` | One versioned, changelog-style summary for each release, including compatibility and operator action. The file at the tagged commit supplies the corresponding Gitea release body. | Current CLI, configuration, operations, integration, architecture, and internal contracts; release procedure; implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, stdout and stderr behavior, summaries, and exit behavior. | Configuration field definitions, complete operating procedures, runtime filesystem layout, and command implementation. |
| Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, runtime state lifecycle, and loading implementation. |
| Operations | `docs/operations.md` | Normal workflows, physical workspace layout, artifacts and metadata, inspection, notification behavior, recovery, cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
| Troubleshooting | `docs/troubleshooting.md` | Recurring symptoms, likely causes, diagnostic steps, safe fixes, and links to normal-operation references. | Complete command and configuration references, routine operating procedures, and implementation detail. |
| Report template surface | `docs/templates.md` | Implemented template files and partials, render-context fields, editing rules, and maintainer-facing template examples. | Weather derivation, module implementation, generated-text validation internals, and operator procedures. |
| External and durable integration contracts | `docs/integrations/` | Weather API, Promptkit, Distributor, external formats and protocols, durable logical paths and schemas, compatibility behavior, and upstream or downstream responsibilities. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, and configuration defaults. |
| Internal subsystem behavior | `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, user-facing contracts, external schemas, operator procedures, and future package plans. |
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed, accepted, deferred, or rejected work; sequencing; gates; implementation status; and task breakdowns. | Implemented behavior reference and durable decision rationale. |
| Complete copyable artifacts | `examples/` | Maintained configuration and other files intended to be copied or run. | Field-by-field reference, command reference, and prose explanation. |
Conditional owners do not require placeholder files or directories. If
Weatherreporter introduces a new public API, consumer interface, release
process, or other durable documentation responsibility, update this policy to
assign its canonical owner when that responsibility is introduced.
## Boundary Rules
### Orientation, Architecture, And Internals
The README owns product orientation. The development policy routes contributors
and owns the concise current package inventory. Architecture owns normative
structure and invariants. Focused internal documents own implementation
behavior. These documents may link to one another but must not maintain
parallel package or behavior references.
### Commands, Configuration, Operations, And Troubleshooting
CLI documentation answers how to invoke Weatherreporter and what its command
interface does. Configuration documentation answers what settings mean.
Operations answers what happens to runtime state and how to operate or recover
the application. Troubleshooting starts from a symptom and leads to diagnosis
and a safe fix.
When a workflow crosses these topics, place the complete procedure with the
document that owns the task and link to the other contracts. Do not duplicate
complete flag, field, or path references to make a workflow self-contained.
### Templates, Integrations, And Implementation
Template documentation defines the maintainer-facing rendering surface.
Integration documentation defines externally observable shapes, logical paths,
protocols, and compatibility behavior. Internal documentation explains how
Weatherreporter produces, transforms, or consumes those contracts.
Internal documents may name a command, field, template value, path, or protocol
to identify a dependency, but must link to its canonical documentation for the
complete definition.
### Release Procedure And Release Notes
The release procedure owns how a maintainer prepares, publishes, verifies, and
recovers from a Weatherreporter release. Release notes under `docs/releases/`
own the concise historical summary for one version and are the checked-in
source for its generated Gitea release body.
Release notes are not current-state reference documents. They may summarize
what changed and link to durable documentation, but they must not become a
second command, configuration, operations, integration, architecture, or
internal reference. Correct the applicable canonical owner in the same change
when a release changes an implemented contract.
The release note at a published tag and the Gitea release generated from it are
historical records. Later corrections on `main` do not rewrite that published
record. Material release errors require the failure handling defined by the
release procedure rather than moving a published tag or overwriting its
release.
### Executable Authority
CLI parsing and help generation are the executable authority for accepted
commands and flags. Configuration structs, defaults, loading, and validation
are the executable authority for configuration behavior. Schemas and embedded
assets are the executable authority for validated formats and template
execution. Tests protect selected contracts and invariants but do not become a
second documentation reference merely by asserting them.
Canonical documentation must be checked against these authorities whenever the
corresponding behavior changes.
### Security Topics
This policy owns what documentation and examples may contain. Architecture owns
application security boundaries and invariants. Configuration owns
credential-supply mechanisms. Operations owns permissions and handling of
sensitive runtime artifacts. Integration documents own consumer-visible
security contracts. Internal documents own implementation mechanisms only.
## Architecture Decision Records
Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
1. title;
2. status;
3. date;
4. context;
5. decision;
6. alternatives considered;
7. consequences.
Use one of these statuses:
- **Proposed:** the decision is under consideration and may change;
- **Accepted:** the decision is approved, whether or not implementation is
complete;
- **Rejected:** the proposed decision was considered and not adopted;
- **Superseded:** a later accepted ADR replaces the accepted decision.
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
as Accepted when the decision has already been made.
Treat the decision content of an Accepted ADR as immutable. A changed decision
requires a later ADR rather than a rewrite of the accepted record. A Superseded
ADR must link to its replacement, and the replacement must link back. Rejected
architectural alternatives belong in the ADR; rejected feature ideas belong in
a roadmap when they need to be retained.
## Document Lifecycle
Create durable current-state documentation with the implementation it
describes. Update its canonical owner in the same change when behavior changes.
If ownership moves, remove the old definition and leave a link where navigation
remains useful.
Roadmaps are temporary coordination documents. When their work is complete,
record completion, move any still-useful decisions or contracts to their
durable owners, update incoming links, and archive or remove the roadmap
according to repository practice. Do not preserve completed roadmaps as a
second current-state reference.
Release notes are durable historical summaries rather than temporary roadmaps.
Keep them concise, retain them after publication, and keep current contracts in
their canonical owners.
Before completing documentation work:
- verify affected behavior and examples;
- check commands, flags, fields, defaults, schemas, paths, and identifiers
against their implementation;
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
- validate links and fenced examples;
- confirm non-owning documents summarize and link rather than redefine;
- remove stale or unsupported claims; and
- confirm that no secrets or sensitive private data were added.

337
docs/policy/testing.md Normal file
View File

@@ -0,0 +1,337 @@
# Testing Policy
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes
cheap**.
We do not optimize for test count, line coverage, exhaustive isolation, or the
fewest possible tests. We optimize for sufficient confidence in important
behavior while imposing as little unnecessary friction as possible on future
development.
## Every Test Has A Cost
Every test has an immediate cost and a continuing lifetime cost. It must be
written, reviewed, executed, understood, diagnosed when it fails, updated when
legitimate behavior changes, and maintained as fixtures and dependencies
evolve.
Tests also create cognitive and architectural friction. They can constrain
refactoring, duplicate policy, slow feedback, add noise to failures, and cause
harmless implementation changes to require unrelated suite edits.
A test is warranted when the confidence it provides justifies those costs.
Apply that judgment at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential
would it be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more
confidence than a smaller, simpler suite would?
Prefer a lean suite that provides sufficient confidence in the risks that
matter without redundant or low-value tests. Some friction is intentional:
tests should make dangerous changes, such as corrupting state, breaking
compatibility, violating security boundaries, or reintroducing subtle defects,
require deliberate review. They should not make ordinary internal changes
needlessly expensive.
Maintenance cost is not a reason to omit testing by default. When omitting a
plausible test, be able to explain why the protected failure is low-risk,
already covered, obvious, reversible, or cheaper to detect elsewhere. Favor
testing when failure would be consequential, subtle, or difficult to observe.
## Default Testing Style
Use a classical or Detroit-style approach:
- Test observable behavior, resulting state, contracts, and invariants.
- Use real internal collaborators when they are fast and deterministic.
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic,
destructive, or external boundaries.
- Prefer package-level behavioral tests over tests coupled to private helpers
or internal call sequences.
- Test exact collaborator interactions only when the interaction itself is a
requirement.
Weatherreporter's important seams include clocks, Promptkit executors, HTTP
services, Distributor uploads, filesystem roots, environment-backed secrets, and any
future source of randomness or nondeterminism.
## Execution Requirements
The [development guide](../development.md) owns baseline repository validation.
The default test suite is:
```sh
go test ./...
```
Run race-enabled tests when a change affects concurrent execution, goroutine
lifecycle, shared mutable state, or cancellation coordination. Use a focused
package command while iterating and `go test -race ./...` when the risk crosses
package boundaries.
Tests in the default suite must be deterministic, offline, and independent of
real credentials. They must not invoke live Weather API, Promptkit providers, or
Distributor services or depend on other mutable external infrastructure.
Tests that require live infrastructure must be explicitly opt-in and clearly
separated from the default suite.
Control clocks, environment variables, filesystem roots, and machine-specific
state when they affect behavior. Tests must be safe to repeat and must not
depend on execution order or state left by an earlier test. Tests that modify
process-global state may remain serial; use `t.Parallel()` only when the test
and its collaborators are actually safe to run concurrently.
## Test Types And Assets
Use each test type where it protects a distinct risk:
- Unit and package tests protect focused domain behavior and invariants through
the narrowest stable boundary.
- Contract tests protect CLI behavior, configuration, durable artifacts,
schemas, templates, integration formats, compatibility, and stable error
identity.
- Integration tests use real deterministic collaborators when correctness
depends on their interaction, while replacing live or nondeterministic
external boundaries.
- App and CLI tests protect representative assembled generation, batch,
inspection, persistence, and notification workflows.
- Fixtures must be minimal, synthetic, versioned with the behavior they
exercise, and free of credentials or private data.
- Golden files are appropriate only when the complete output is intentionally
stable and semantic review of updates is practical.
- Failure-path tests should cover consequential malformed input, dependency
failure, cancellation, partial results, and recovery behavior.
## What Deserves Tests
Prioritize tests for:
1. CLI, configuration, artifact, template, integration, and package contracts.
2. Meteorological domain rules and important invariants.
3. Boundary conditions and malformed input.
4. Failure handling, cancellation, retries, recovery, and partial success.
5. Serialization, schemas, compatibility, and round trips.
6. Previously observed or plausible regressions.
7. Representative app and CLI workflows.
A package-level contract is behavior relied upon by another package or major
collaborator, not every observable implementation detail.
For data integrity, destructive operations, compatibility, security,
concurrency, idempotency, or recovery, presume that durable tests are required
unless the behavior is already credibly protected at another layer.
Do not add tests merely because a function, branch, or line exists. Do not add
a test when the same meaningful risk is already adequately protected
elsewhere.
## Choose The Right Boundary
Test through the narrowest stable boundary that expresses the behavior clearly.
That may be:
- a small pure function when dense domain logic is clearest there;
- a package operation when several internal collaborators jointly produce the
behavior; or
- a larger integration or app boundary when correctness emerges from
interaction.
Do not force every behavior through oversized workflow tests. Do not test every
private helper merely because it exists. Choose the boundary that provides
durable confidence with the least incidental coupling.
## Test Behavior, Not Implementation
A test should protect a decision, contract, or invariant, not memorialize the
current implementation. Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test is suspect when its main purpose is to detect that someone changed a
private constant, renamed or split a helper, reordered equivalent operations,
changed incidental formatting, replaced one correct algorithm with another, or
refactored private structure without changing behavior.
Refactoring should normally require no test edits unless the changed structure
is itself contractual. A test can be factually correct and still have negative
value when the behavior it protects is too incidental to justify its future
cost.
Use these expectations when evaluating failures:
| Change | Expected effect on tests |
| --- | --- |
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and pass. |
| Internal default change with no contractual significance | Tests should normally derive expectations from configuration or relationships rather than duplicate the old value. |
| Intentional change to user-visible behavior, policy, schema, or compatibility | Relevant tests should be reviewed and changed deliberately. |
| Accidental contract or invariant violation | Tests should fail; fix production code rather than rewriting tests to accept the defect. |
A failing test is not necessarily a test that should be edited. Many tests may
correctly fail because of one production defect. The maintenance smell is a
correct internal change that requires unrelated expectation changes throughout
the suite.
## Separate Mechanism From Policy
Do not duplicate configurable thresholds and defaults throughout the suite.
Test mechanisms relationally: a configured valid value is accepted, a value
outside the permitted relationship is rejected, and runtime behavior respects
the configured value.
Test an exact default when its literal value is itself a documented user,
operational, safety, protocol, or compatibility contract. The same distinction
applies to timeouts, capacities, retry counts, ranges, thresholds, and output
limits.
When concurrency limits are introduced, distinguish configuration enforcement
from runtime enforcement. Validate accepted and rejected settings separately
from measuring whether observed peak concurrency respects the configured
limit.
## Avoid Semantic Duplication
Each behavior should have a clear test owner:
- CLI parser tests own arguments, flags, and command construction.
- Config tests own loading, precedence, defaults, secrets, and validation.
- Domain tests own weather transformations and invariants.
- Adapter tests own HTTP, Promptkit/provider, and upload boundaries.
- Orchestrator tests own workflow ordering, persistence, partial success, and
failure propagation.
- State tests own path derivation, atomic artifacts, lookup, and round trips.
- Template and generated-text tests own schemas, render contexts, and rendered
output contracts.
Higher-level tests should not repeat every lower-level case. Tests that are
individually reasonable may still be collectively redundant; assess the
marginal protection of each additional test.
## Use Test Doubles Deliberately
Choose the least elaborate double that provides the required control or
observation:
1. Prefer real collaborators when they are fast and deterministic.
2. Use small in-memory fakes when realistic stateful behavior helps.
3. Use stubs when a dependency only needs controlled responses.
4. Use mocks when the interaction itself is contractual.
Mocks are appropriate for requirements such as uploading exactly once, saving
metadata before notification, propagating cancellation to Promptkit, or
avoiding an external call after an earlier workflow failure. Do not use mocks
merely to isolate every object or reproduce the implementation's call graph.
## Go-Specific Guidance
Use:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic Weather API interactions;
- test-controlled clocks for periods and RunIDs;
- fake Promptkit executors or provider clients for Promptkit behavior;
- fake upload clients for Distributor behavior;
- fuzz tests when parsers, normalization, or path handling have a broad and
consequential input space;
- golden files only when complete output stability is intentional; and
- a small number of representative app and CLI workflow tests.
Avoid exact error-string assertions unless wording is contractual. Prefer
`errors.Is`, `errors.As`, typed errors, structured fields, or the smallest
stable semantic fragment that identifies the failure. At CLI boundaries,
prefer structured summaries, exit behavior, and stable classifications over
snapshots of complete diagnostic wording.
Golden-file updates must require an explicit local flag. Ordinary validation
must never update golden files automatically, and maintainers must inspect the
semantic diff before accepting an update.
Keep tests readable and direct. Helpers and fixture frameworks must earn their
maintenance cost; do not build elaborate infrastructure for small or isolated
needs.
## Coverage
Coverage is a diagnostic, not a target. Use it to find untested critical
branches and unexpectedly weak packages. Do not write low-value tests solely
to increase a percentage or infer quality from coverage alone.
Pure domain logic will often warrant higher coverage than CLI wiring or thin
external adapters. Uneven coverage is acceptable when it reflects risk.
## Regression Tests
A bug fix should normally include a regression test that fails before the fix
and passes afterward. Prefer the narrowest durable test of the violated
contract or invariant.
Retain the test when the defect could realistically recur and its consequences
justify the ongoing cost. Remove or consolidate it if the design makes
recurrence implausible or a stronger invariant test subsumes it.
## Deleting Or Rewriting Tests
Tests are maintained code, not permanent historical artifacts. Delete or
rewrite a test when its maintenance cost exceeds the confidence it provides.
Candidates include tests that:
- require edits after harmless internal changes;
- assert private constants without protecting a real contract;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output;
- protect risks already covered more effectively elsewhere; or
- are flaky, misleading, obsolete, or no longer correspond to a plausible
failure.
Test removal must be deliberate and within the scope of the change. Identify
the behavior the test protected and show that the behavior is covered more
effectively elsewhere or that the failure is no longer plausible enough to
justify durable coverage. Replace several brittle tests with one stronger
behavior or invariant test when appropriate.
Do not delete or weaken a test merely because it fails after a production
change. First determine whether the failure exposes an accidental regression,
an intentional contract change, or an implementation-coupled assertion.
## Reviewing A Proposed Test
When a proposed test's value or durability is not self-evident, ask:
1. What realistic defect would it catch, and how consequential is that defect?
2. Is the behavior already protected elsewhere?
3. Which layer should own the test?
4. Does it assert a durable contract or incidental implementation detail?
5. What should cause it to fail, and what legitimate changes should not?
6. Could a smaller or more direct test protect the same risk?
7. What ongoing maintenance, execution, and diagnostic cost will it impose?
Written answers are not required for every routine test. Do not add a test when
its expected lifetime cost exceeds its expected protective value.
## Definition Of Sufficient
A suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- consequential regressions are credibly protected against silent recurrence;
- data integrity, destructive operations, compatibility, security,
concurrency, idempotency, and recovery receive risk-appropriate protection;
- external boundaries have realistic local integration coverage;
- representative complete workflows are tested;
- failures provide useful signal rather than redundant noise; and
- legitimate internal changes usually do not require test edits.
Sufficiency is a risk judgment, not a coverage percentage or test count.
Reassess it as Weatherreporter, its users, and the consequences of failure
evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect
> after the fact. Test lightly where failure is obvious, reversible, and
> inexpensive.

269
docs/release.md Normal file
View File

@@ -0,0 +1,269 @@
# Release Procedure
## Release Model
Weatherreporter publishes executable binaries through tagged commits on
`main`. Releases use stable semantic-version tags in the form
`vMAJOR.MINOR.PATCH`. The current pipeline does not publish prereleases.
Every release has one nonempty, version-matched note at
`docs/releases/<tag>.md`. After the tag is pushed, the Woodpecker release
pipeline validates the tagged source, builds six binaries, creates SHA-256
checksums, and creates the corresponding Gitea release. The pipeline uses the
checked-in release note as the Gitea release body and does not overwrite an
existing release.
Before `v1.0.0`, a minor release may deliberately change user-facing
interfaces when its release note explains the compatibility impact and
required operator action. Patch releases must not intentionally break the
documented CLI, configuration, durable artifact, or integration contracts in
their minor line.
Published tags and their generated releases are immutable. Never move, reuse,
or delete a published tag, and never manually overwrite the release produced
from it.
## Select The Version And Write The Release Note
Choose an unpublished version and export it as `RELEASE_VERSION`. Run the
commands in this procedure from the Weatherreporter repository root in one
POSIX shell:
```sh
export RELEASE_VERSION=vMAJOR.MINOR.PATCH
```
Create `docs/releases/$RELEASE_VERSION.md` with this structure:
```markdown
# Weatherreporter vMAJOR.MINOR.PATCH
This release ...
## Summary
Summarize the release's purpose and most important outcomes.
## Compatibility
State compatibility with the preceding release and identify any changed CLI,
configuration, durable artifact, integration, or operating contract.
## Upgrade
State the operator actions required to upgrade, or state that no special
action is required.
## Changes
Describe the material user-visible, operational, and maintainer-visible
changes. Link to canonical documentation for exact current contracts.
```
The note is a concise changelog and adoption aid, not a replacement for current
documentation. Update every affected canonical document in the same candidate
commit. Do not include credentials, private infrastructure details, or claims
that are not true of the candidate.
Require the version, path, heading, and minimum sections before continuing:
```sh
set -eu
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
RELEASE_NOTE="docs/releases/$RELEASE_VERSION.md"
export RELEASE_NOTE
test -s "$RELEASE_NOTE"
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
grep -Fx '## Summary' "$RELEASE_NOTE"
grep -Fx '## Compatibility' "$RELEASE_NOTE"
grep -Fx '## Upgrade' "$RELEASE_NOTE"
grep -Fx '## Changes' "$RELEASE_NOTE"
```
## Validate The Candidate
Run the same substantive checks enforced by the tag pipeline before committing
the release note:
```sh
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
git diff --check
git diff --cached --check
```
Follow every added or changed Markdown link and confirm that its local target
exists. Review the candidate for generated binaries, test output, credentials,
temporary files, workspace files, replacements, vendored dependencies, and
other files that do not belong in source control.
## Publish The Candidate Commit
Commit the release note and any final current-state documentation updates, then
push `main` through the ordinary repository workflow:
```sh
git add "$RELEASE_NOTE"
git commit -m "Document Weatherreporter $RELEASE_VERSION"
git push origin main
```
Do not tag an uncommitted or unpushed candidate. Record and export the exact
candidate commit after the push:
```sh
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
export RELEASE_COMMIT
```
## Guard And Tag The Candidate
Run this guard immediately before creating the tag. It requires a clean
checkout on synchronized `main`, valid module hygiene, the version-matched
release note, and an unpublished local and remote tag:
```sh
check_release_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
gowork_value=$(go env GOWORK)
case "$gowork_value" in
''|off) ;;
*)
printf '%s\n' "active Go workspace: $gowork_value" >&2
return 1
;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
return 1
fi
test -s "$RELEASE_NOTE"
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
git fetch origin main --tags
test "$RELEASE_COMMIT" = \
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
return 1
fi
if test -n "$(
git ls-remote --tags origin \
"refs/tags/$RELEASE_VERSION" \
"refs/tags/$RELEASE_VERSION^{}"
)"
then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
return 1
fi
}
check_release_candidate
```
Create a lightweight tag, matching Weatherreporter's existing release tags,
and bind it explicitly to the guarded commit:
```sh
git tag "$RELEASE_VERSION" "$RELEASE_COMMIT"
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = commit
test "$(git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}")" = \
"$RELEASE_COMMIT"
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
```
If inspection finds an error, delete the unpublished local tag, correct the
candidate, and repeat the procedure. Once the tag is pushed, it is immutable.
## Publish And Verify The Release
Push only the selected tag ref. Do not use `git push --tags`:
```sh
git push origin \
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
The tag event starts the release pipeline. Its validation step rejects a
non-stable semantic tag, a missing release note, module or repository hygiene
violations, and any failing test, race test, vet, build, module-tidiness,
formatting, or whitespace check. Its build step also verifies that the host
binary reports `weatherreporter $RELEASE_VERSION`.
Wait for the pipeline to succeed, then confirm that the Gitea release:
- targets `RELEASE_COMMIT` through `RELEASE_VERSION`;
- is titled `Weatherreporter $RELEASE_VERSION`;
- uses `RELEASE_NOTE` from the tagged commit as its body;
- contains `SHA256SUMS`; and
- contains Linux, macOS, and Windows binaries for both `amd64` and `arm64`,
named `weatherreporter-$RELEASE_VERSION-<os>-<arch>` with `.exe` on Windows.
Compare the remote tag with the guarded commit:
```sh
remote_commit=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
awk 'NR == 1 { print $1 }'
)
test "$remote_commit" = "$RELEASE_COMMIT"
```
Download `SHA256SUMS` and every release binary into a new temporary directory,
run `sha256sum --check SHA256SUMS`, and execute the binary for the maintainer's
host platform with `--version`. It must print exactly:
```text
weatherreporter vMAJOR.MINOR.PATCH
```
## Failed Publication And Corrections
If the tag pipeline fails after publication, preserve the tag and diagnose the
failure from the pipeline logs. Fix the cause on `main`, select a new patch
version, prepare a new release note, and repeat the complete procedure. Do not
move or recreate the failed published tag.
Do not manually edit an automatically generated Gitea release or republish its
assets. A wording-only correction may be committed to the historical document
on `main`, with an explicit correction note, but it does not alter the file at
the tag or the generated release. Publish a new patch release when the error is
material to installation, compatibility, security, or operation.

134
docs/releases/v0.9.0.md Normal file
View File

@@ -0,0 +1,134 @@
# Weatherreporter v0.9.0
Weatherreporter `v0.9.0` replaces its external Scriptorium execution path with
an in-process Promptkit integration and makes prompt preparation, execution,
validation, and failure artifacts first-class parts of each report run.
## Summary
- Promptkit `v0.4.0` now executes all generated text for Daily, Today,
Tomorrow, and Hourly reports.
- The four exact-version prompts and their JSON Schemas are embedded in the
Weatherreporter binary.
- Prompt preparation and execution have separate durable, redacted provenance
records, while sensitive prompt debugging is explicit and stored outside the
managed workspace.
- Weather API collection now performs a warmup request and retries transient
transport, read, and selected HTTP failures.
- Release binaries now report their embedded version and are published with
checksums through a guarded Woodpecker pipeline.
## Compatibility
This pre-`v1` minor release contains intentional configuration, CLI, and
artifact changes that require review when upgrading from `v0.8.0`.
- The `scriptorium:` configuration section is no longer supported. A file that
contains it fails with a migration error instead of silently ignoring it.
Use `promptkit:` configuration instead.
- The previously exposed but unfinished three-day, weekend, and storm report
surfaces have been removed. Supported report IDs and `generate` commands are
`daily`, `today`, `tomorrow`, and `hourly`. The retired `storm_id`
Distributor template variable is also no longer accepted.
- Generate and batch result items now expose `preparationPath` and
`executionPath` instead of the Scriptorium-oriented `preflightPath` and
`generatedTextResultPath`. An opt-in prompt capture may also add
`llmDebugPath`.
- New runs write `weatherreporter.metadata.v2`, which records Promptkit
preparation and execution paths. Inspection and prior-run lookup continue to
read existing `weatherreporter.metadata.v1` records.
- The built-in `weather_api.precision` default changed from `1` to `0`.
Configurations that explicitly set a value retain that value.
- Report prose may differ because the embedded prompt corpus, structured
output path, alert presentation, and SPC background context have changed.
The documented Go version remains 1.26. Distributor integration remains at
`v0.5.0`. Existing managed workspaces do not require conversion.
## Upgrade
Replace the old Scriptorium block in the Weatherreporter configuration. The
smallest equivalent Promptkit block is:
```yaml
promptkit:
timeout: 2m
```
The embedded prompts default to the Promptkit `gemini-flash-latest` profile.
Ensure that the selected profile's credential environment variable is present,
or configure `promptkit.profile`, an external `profile_file` or `profile_dir`,
or the optional `promptkit.local` backend. Direct per-request API keys are not
supported by Weatherreporter.
Before upgrading automation or downstream processing:
1. remove any `three-day`, `weekend`, or `storm` command, report override, and
`storm_id` template usage;
2. update consumers of action-summary JSON to use the new preparation and
execution path fields;
3. decide whether to retain the new precision default or explicitly configure
the previous value; and
4. preserve the existing workspace if historical V1 runs must remain
inspectable.
Scriptorium, its executable configuration, and its external prompt corpus are
no longer needed by Weatherreporter. See the
[configuration reference](../config.md), [CLI reference](../cli.md), and
[Promptkit integration](../integrations/promptkit.md) for the current
contracts.
## Changes
### Prompt Execution And Artifacts
- Added a project-owned Promptkit adapter with exact prompt and profile
inspection, prepare-once execution, error classification, and bounded
execution timeouts.
- Embedded version `1.0.0` of the Daily, Today, Tomorrow, and Hourly prompts and
their private generated-text schemas.
- Added durable preparation and execution receipts with prompt, profile,
backend, model, hashes, timings, validation status, classified failures, and
paths to every artifact reached during the run. Credentials, endpoints,
rendered messages, request parameters, and generated content are excluded
from these managed records.
- Added `--llm-debug-dir` for explicitly requested content-rich diagnostics.
Debug output must use an absolute path outside the managed workspace and is
written with restrictive filesystem permissions.
- Preflight now validates each exact prompt and selected profile before weather
collection. Batch execution validates every candidate first, collects once,
and retains independent report progress and failure artifacts.
See the [operations guide](../operations.md) for artifact layout, inspection,
debug handling, and recovery.
### Weather Collection And Report Content
- Added a `/conditions/current` warmup before source collection and automatic
retry for transient transport and response-read failures and HTTP `408`,
`429`, `500`, `502`, `503`, and `504` responses.
- Changed the default upstream precision query value to `0`.
- Added embedded background definitions for recognized SPC categorical,
tornado, wind, and hail outlook products.
- Made the Alert Digest more concise: alert descriptions are omitted, and an
SPC-only digest is rendered only for Enhanced, Moderate, or High categorical
risk.
- Removed duplicated alert detail from the prompt-facing metadata module; the
alert digest remains its single prompt-facing owner.
See the [Weather API integration](../integrations/weatherapi.md) for the request,
retry, and response contract.
### CLI, Documentation, Testing, And Releases
- Added `weatherreporter --version`; tagged binaries report `v0.9.0`, while
ordinary local builds report `development`.
- Reworked CLI summaries and inspection coverage around the Promptkit artifact
lifecycle and retained partial-result behavior.
- Reorganized contributor, policy, user, operator, integration, template, and
internal documentation around explicit canonical owners.
- Added focused single-report, batch, CLI, Promptkit adapter, durable-state,
and artifact-path coverage while simplifying orchestration internals.
- Added guarded tag validation and reproducible release builds for Linux,
macOS, and Windows on `amd64` and `arm64`, with SHA-256 checksums and
changelog-backed Gitea releases.

View File

@@ -1,17 +1,13 @@
# Future Roadmap
This roadmap contains project work that is not implemented. Current behavior is
documented outside `docs/roadmap/`.
This roadmap contains future work only. Each section identifies its planning
status; current behavior is documented outside `docs/roadmap/`.
## Automatic Storm Monitoring
Manual Storm Report generation is available through:
Status: Proposed and unimplemented.
```sh
weatherreporter generate storm --start TIME --end TIME
```
Automatic storm-event evaluation is not implemented.
Storm reporting, whether manual or automatic, is unimplemented.
Possible direction:
@@ -19,7 +15,7 @@ Possible direction:
story context, hourly thresholds, and material forecast changes.
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
3. Persist storm lifecycle state.
4. Generate or update Storm Reports only when a meaningful event is present.
4. Generate or update a storm report only when a meaningful event is present.
5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states:
@@ -32,11 +28,13 @@ Possible lifecycle states:
- `resolved`
Before implementation, the design must preserve scheduled report behavior,
manual Storm Report generation, inspectable evaluator failures, and fixture
coverage for deterministic candidate detection.
inspectable evaluator failures, and fixture coverage for deterministic
candidate detection.
## Future Report Types
Status: Proposed and unimplemented.
Possible future report types:
- a short-fuse planning report distinct from the implemented Hourly Report, if
@@ -46,12 +44,13 @@ Possible future report types:
- archive-focused report variants if generated report history becomes a
first-class product
New reports should keep report identity, prompt IDs, templates, valid-period
resolution, artifact grouping, batch output names, and comparison policy inside
`internal/report`.
New reports should preserve the boundaries documented in the [report registry
internals](../internal/report-registry.md).
## Future Modules
Status: Proposed and unimplemented.
Possible future modules:
- `hourly_table` for compact valid-period hourly facts
@@ -59,7 +58,7 @@ Possible future modules:
Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
- `storm_window_summary` if manual or automatic storm reports need a dedicated
prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
@@ -71,7 +70,9 @@ QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is
represented in `CollectedFacts`.
Future module work should preserve these boundaries:
Future module work should preserve the boundaries documented in [fact
contracts](../internal/facts.md), [module internals](../internal/module.md), and
[briefing internals](../internal/briefing.md):
- keep upstream collection in app orchestration
- keep upstream collection out of modules
@@ -82,9 +83,13 @@ Future module work should preserve these boundaries:
## Distributor Notification Enhancements
Distributor notification uploads one managed Markdown report per successful
generated report through the configured HTTP upload pipeline. The following
enhancements are not implemented:
Status: Proposed and unimplemented.
Single-report and batch Distributor notification are implemented. Current
behavior is documented in the [Distributor adapter guide](../internal/distributor-adapter.md),
[Distributor integration guides](../integrations/distributor/), and
[operations guide](../operations.md). The following enhancements remain
unimplemented:
- `failure_policy: warn`
- uploading metadata, module snapshots, data packages, or preflight artifacts
@@ -100,7 +105,9 @@ while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations
These ideas are not implemented:
Status: Proposed and unimplemented.
These ideas remain unimplemented:
- native LLM client inside `weatherreporter`
- database-backed state
@@ -119,6 +126,8 @@ must not describe these as available behavior.
## Deferred Refactors
Status: Deferred.
These refactors should remain deferred until new requirements or recurring
maintenance costs make the added abstraction worthwhile:

View File

@@ -0,0 +1,555 @@
# Promptkit Migration Implementation Plan
Status: Completed; Stages 119 passed their exit gates.
## Purpose And Authority
This document records the completed implementation of the
[Promptkit migration roadmap](promptkit.md) and its post-implementation audit
remediation. The feature roadmap records scope, user intent, policy choices,
and the implemented end state. This plan records implementation sequence,
tests, and completion gates.
Stages 1219 were completed in order. They fixed additional defects exposed by
their required tests only when those defects were within the same stated
contract; they did not add new product behavior or reinterpret roadmap
decisions.
This plan follows the repository's
[architecture](../policy/architecture.md),
[documentation](../policy/documentation.md), and
[testing](../policy/testing.md) policies.
## Continuing Invariants
- Keep `gitea.maximumdirect.net/eric/promptkit` pinned at exactly `v0.4.0`.
- Keep Promptkit types inside `internal/adapters/promptkit`, its tests, and the
external prompt-asset contract test.
- Preserve one Promptkit engine per `generate` or `run` invocation and one
shared engine for every sequential report in a batch.
- Preserve exact prompt version `1.0.0`, the exact persisted YAML data-package
bytes, prepared execution, and preparation persistence before provider work.
- Do not add retries, repair attempts, concurrent batch generation, direct
Markdown generation, arbitrary backend registration, or live-provider
tests.
- Keep ordinary artifacts, errors, logs, and summaries free of credentials,
rendered messages, schemas, input bodies, generated bodies, endpoints, and
full effective parameter maps.
- Keep sensitive debug artifacts opt-in, outside normal state, owner-only,
atomic, and free of credentials.
- Treat an artifact path as reached only after the corresponding write or copy
succeeds. Never persist or summarize a merely derivable future path.
- Preserve every safe reached path in partial app and CLI results even when a
later persistence, validation, rendering, copy, or notification step fails.
- Keep v1 metadata read compatibility and write only v2 metadata for new runs.
- Keep the default test suite deterministic, offline, and credential-free.
- Run `git diff --check` before completing every stage. Run the full repository
gate in Stage 19.
## Completed Migration Summary
Stages 111 are implemented and committed. They remain summarized here to
preserve the history and dependencies of the follow-up work.
| Stage | Completed outcome |
| --- | --- |
| 1 | Removed the unfinished three-day, weekend, and storm product surfaces and retained Daily, Today, Tomorrow, and Hourly with exact prompt version `1.0.0`. |
| 2 | Promoted the four operational prompts and canonical schemas into the embedded `internal/promptassets` source used by Promptkit and generated-text validation. |
| 3 | Added the project-owned `internal/promptexec` inspection, preparation, execution, validation, debug, and error contract. |
| 4 | Added the Promptkit v0.4.0 adapter with prepared execution, explicit value mapping, safe error classification, and offline model-client tests. |
| 5 | Added Promptkit-era preparation and execution artifacts, metadata v2, new paths, and v1 decoding support. |
| 6 | Added explicitly rooted, permission-restricted, atomic LLM debug persistence. |
| 7 | Added Promptkit configuration, executor composition, and pre-collection prompt/profile/credential inspection. |
| 8 | Cut single-report generation over to prepared Promptkit execution and v2 persistence. |
| 9 | Added `--llm-debug-dir` and Promptkit-era single-report summary fields. |
| 10 | Cut morning and evening batches over to one shared Promptkit executor and removed Scriptorium code, configuration, and dependency metadata. |
| 11 | Updated canonical Promptkit documentation, removed the temporary Scriptorium corpus, and ran the available repository checks. |
The post-implementation audit confirmed the principal dependency and package
boundaries, but found incorrect reached-path bookkeeping, incomplete execution
artifact updates, insufficient artifact validation, extensive loss of
behavioral tests during the final cutover, and roadmap lifecycle text that was
not finalized. The completed remediation addressed those findings without
changing the intended feature scope.
| Stage | Completed outcome |
| --- | --- |
| 12 | Corrected reached-path bookkeeping across metadata, app results, batch items, and CLI summaries. |
| 13 | Hardened Promptkit-era durable state validation and restored v1/v2 state coverage. |
| 14 | Recorded every downstream path reached after completed prompt execution. |
| 15 | Restored assembled single-report behavioral and failure coverage. |
| 16 | Simplified prompt-generation orchestration while preserving behavior. |
| 17 | Restored assembled batch, planning, artifact, and notification coverage. |
| 18 | Restored supported CLI, summary, safety, and historical inspection coverage. |
| 19 | Reconciled canonical documentation and passed the complete repository verification gate. |
## Stage 12: Correct Reached-Artifact Bookkeeping
Status: Completed.
### Goal
Make metadata, app results, batch items, and CLI summaries truthful at every
failure boundary: a nonblank path means that artifact was successfully
created.
### Work
1. Change `state.BuildPromptMetadataFromBriefingMetadata` so it initializes
identity, schema, metadata destination, and only artifacts already saved at
the call site. It must not prepopulate raw-output, normalized-text,
render-context, managed-report, preparation, execution, notification, or
output-copy paths.
2. In `generatePromptReport`, assign each metadata and `ReportResult` path
immediately after that artifact write succeeds and before attempting the
next write. In particular:
- do not initialize `ReportResult.ReportPath` from `Store.Paths`;
- record a saved failed-preparation receipt in the result before saving
metadata;
- record a saved failed or completed execution receipt before saving
metadata;
- retain raw, normalized, context, report, copy, and notification paths
when a later step fails; and
- keep `MetadataPath` unchanged when a metadata rewrite fails, because the
prior successfully written metadata record remains the reached version.
3. Remove batch-item prepopulation from derived `Store.Paths` values.
`BatchReportResult` receives paths only from the returned `ReportResult` or
from a write that the batch itself successfully completed.
4. Preserve current CLI field names and omission behavior. Human and JSON
summaries must omit every unreached path.
5. Do not change artifact locations, filenames, schemas, report output, or
notification policy in this stage.
### Tests
- Add focused app tests for one representative report using real temporary
state plus a narrow failure-injecting store wrapper.
- Fail the next persistence step immediately after a successful preparation
receipt, execution receipt, raw output, normalized output, render context,
managed report, output copy, and notification artifact; assert that the
returned result contains every reached path and no future path.
- Include one preparation failure, one operational execution failure, and one
completed validation rejection to cover the three execution outcome shapes.
- Add batch and CLI summary assertions proving unreached paths are omitted.
- Run:
```sh
go test ./internal/state ./internal/app ./internal/cli
git diff --check
```
### Exit Gate
Every nonblank path in newly written metadata, app results, batch items, and
CLI summaries names an artifact that exists. Every safe artifact successfully
written before a later failure remains discoverable from the returned partial
result.
## Stage 13: Harden Durable State Contracts And Restore State Coverage
Status: Completed.
### Goal
Make the v1/v2 wire boundary and Promptkit-era artifact validation explicit,
strict, and durably tested.
### Work
1. Strengthen `PromptPreparationArtifact.Validate`:
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
data-package path, nonzero start/end times, nonnegative duration, and an
end not earlier than the start;
- for success, require preparation provenance, prohibit an error, and
require its prompt ID/version and data-package path to match the top-level
artifact;
- for failure, require a classified bounded error and prohibit fabricated
preparation provenance.
2. Strengthen `PromptExecutionArtifact.Validate`:
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
nonzero start/end times, nonnegative duration, and an end not earlier than
the start;
- for success and validation rejection, require provenance and completed
validation, prohibit an operational error, and require the provenance
prompt ID/version to match the artifact;
- do not compare the provenance RunID with the Weatherreporter RunID because
the provenance value is Promptkit's run identity;
- for operational failure, require a classified bounded error and prohibit
invented provenance or completed validation.
3. Validate required provenance fields for completed executions, including
Promptkit RunID, prompt and rendered hashes, selected profile/backend/model,
and data-package path. Permit usage counters and generated hash to be zero
when the provider legitimately reports no value.
4. Restore focused filesystem and metadata tests for:
- exact v2 paths and filenames;
- preparation/execution round trips and required fields;
- metadata v2 round trips without legacy aliases;
- v1 decoding, normalized internal aliases, and v1-preserving re-marshaling;
- unknown schema rejection;
- report listing, RunID lookup, source/module/data-package inspection, and
retained v1 behavior for historical report IDs;
- atomic writes and unsafe workspace/path rejection; and
- prior-snapshot behavior for the four supported report IDs.
5. Adapt useful tests from the deleted filesystem suite rather than recreating
redundant low-value cases. Do not restore Scriptorium writes or retired
report behavior.
### Tests
Run:
```sh
go test ./internal/state ./internal/app
git diff --check
```
### Exit Gate
The state package rejects incomplete or contradictory Promptkit-era artifacts,
reads historical v1 records, writes only valid v2 records, and has focused
offline coverage for its durable compatibility and filesystem contracts.
## Stage 14: Complete Execution-Artifact Path Tracking
Status: Completed.
### Goal
Make `PromptExecutionArtifact.Paths` accurately record every downstream
artifact reached after a completed Promptkit run.
### Work
1. Treat the execution artifact as an atomically updated durable record of the
completed Promptkit execution and subsequent artifact destinations. Its
status, provenance, validation, usage, and timing remain the provider-run
outcome; later application failures do not change a successful Promptkit
status into an execution failure.
2. Save the initial execution artifact after raw output is persisted, with
`RawOutputPath` populated.
3. After each later successful write, update and atomically resave the same
execution artifact with the corresponding reached path:
- normalized generated text;
- render context;
- managed Markdown report;
- an explicitly requested extra output copy, only after the copy succeeds;
and
- a Distributor notification artifact, including a persisted failure or
status artifact when notification produced one.
4. Keep metadata and execution-artifact path values consistent after every
successful checkpoint. Save the execution artifact before metadata so a
metadata failure does not erase knowledge of a reached downstream artifact.
Failure to update the execution artifact is terminal and returns a partial
result containing the downstream artifact that was already written.
5. Refactor finalization return values only as needed to tell the orchestration
layer which copy and notification paths were actually written. Distributor
must continue uploading only the managed Markdown report.
6. A validation-rejected execution ends after raw output and therefore records
only the raw-output path. An operational execution failure has no completed
provenance and records only safe paths reached before that failure.
### Tests
- Add table-driven execution-artifact lifecycle tests for success and every
downstream failure point.
- Load the persisted execution artifact after normalized-text, context,
template, copy, metadata, and notification failures and assert its status and
exact reached paths.
- Assert that execution artifacts never contain generated bodies, rendered
prompts, schemas, endpoints, parameters, or credentials.
- Run:
```sh
go test ./internal/state ./internal/app
git diff --check
```
### Exit Gate
For every completed Promptkit run, its execution artifact contains exactly the
safe downstream paths reached by the workflow and remains semantically correct
when a later application stage fails.
## Stage 15: Restore Single-Report Behavioral Coverage
Status: Completed.
### Goal
Restore the risk-based application coverage removed during final cutover and
prove the complete single-report Promptkit workflow through project-owned
boundaries.
### Work
1. Reintroduce a focused app test harness using real state, prompt-input,
generated-text validation, render contexts, and templates with deterministic
collector, executor, notifier, clock, and filesystem boundaries.
2. Add representative successful workflows for Daily, Today, Tomorrow, and
Hourly. Verify report identity, exact prompt version, one collection, exact
persisted YAML bytes passed to the executor, expected template output,
optional copy behavior, and managed-report notification source.
3. Cover the required failure matrix:
- inspection and missing credentials before collection;
- preparation failure and callback persistence failure before provider work;
- execution-time credential disappearance;
- capacity rejection without retry;
- cancellation and deadline;
- generation and operational-validation failure;
- completed Promptkit schema rejection with retained raw output;
- generated-text decode/domain rejection;
- render-context and template failure;
- output-copy failure; and
- notification failure.
4. Verify preparation persistence precedes provider execution, debug-write
failure prevents provider execution, and execution-debug failure preserves
previously reached normal and debug artifacts.
5. Verify Recent Changes, prior-snapshot selection, output naming, and
Distributor template values for all four retained reports.
6. Adapt useful tests from the deleted app suite. Omit Scriptorium mechanics,
subprocess interaction assertions, and retired report products.
7. Fix defects exposed by these tests only when the expected behavior is
already decided by the roadmap or canonical policy. Record any new product
question instead of silently choosing it.
### Tests
Run:
```sh
go test ./internal/app
go test -race ./internal/app ./internal/adapters/promptkit
git diff --check
```
### Exit Gate
The single-report workflow has deterministic behavioral coverage for all four
reports, all consequential failure stages, artifact ordering, partial results,
debug isolation, output copying, and notification behavior.
## Stage 16: Refactor Prompt Generation Orchestration
Status: Completed.
### Goal
Reduce the complexity and duplicated persistence logic in
`generatePromptReport` without changing observable behavior.
### Work
1. Use the Stage 1215 tests as the refactoring safety boundary. Do not weaken
assertions to accommodate structural changes.
2. Split the current orchestration into small app-owned operations with clear
inputs and outcomes for:
- deterministic input and initial state construction;
- preparation callback persistence;
- preparation-failure persistence;
- operational-execution-failure persistence;
- completed execution and raw-output persistence;
- normalized text and render-context persistence;
- managed report, optional copy, metadata, and notification finalization;
and
- reached-path updates shared by success and failure paths.
3. Keep workflow order visible in one coordinator. Do not introduce a generic
workflow engine, hidden retry loop, provider-specific app type, or mutable
global state.
4. Centralize the repeated rule that a successful artifact write updates the
result before any following write can fail.
5. Preserve error identities, safe text, atomic writes, exact bytes, debug
ordering, partial results, and notification behavior.
### Tests
Run:
```sh
gofmt -w internal/app/*.go
go test ./internal/app ./internal/state ./internal/cli
go test -race ./internal/app
git diff --check
```
### Exit Gate
The top-level coordinator communicates the workflow order without containing
the full persistence implementation, duplicate failure branches are reduced,
and every Stage 1215 behavioral test passes unchanged.
## Stage 17: Restore Batch Behavioral Coverage
Status: Completed.
### Goal
Re-establish confidence that morning and evening batches preserve their
pre-migration behavior while sharing one Promptkit executor.
### Work
1. Add assembled batch tests proving:
- one executor factory call and one executor per CLI invocation;
- inspection of the full candidate set before collection;
- one weather collection;
- existing morning/evening planning and ordering;
- sequential execution through the shared executor;
- continuation after an independent report failure;
- no retry after capacity rejection;
- distinct identities and debug directories for multiple Daily dates; and
- exact reached paths on successful and failed batch items.
2. Restore notification coverage for disabled notification, suppressed
per-report notification, all-success batch notification, skipped
notification after report failure, and persisted notification failure/status
artifacts.
3. Restore output-directory, Today/Tomorrow naming, dynamic Daily planning,
prior-snapshot, and managed-Markdown upload-source coverage.
4. Adapt useful tests from the deleted batch portions of the app and CLI suites.
Do not restore retired report cases or Scriptorium fakes.
5. Fix only roadmap-defined batch regressions exposed by the restored tests.
### Tests
Run:
```sh
go test ./internal/app ./internal/cli
go test -race ./internal/app
git diff --check
```
### Exit Gate
Morning and evening batches are covered as assembled sequential workflows and
demonstrably preserve collection, planning, continuation, output, debug,
artifact, and notification contracts with one Promptkit executor.
## Stage 18: Restore CLI And Inspection Coverage
Status: Completed.
### Goal
Restore the user-facing command, summary, and historical inspection contracts
removed with the old root test suite.
### Work
1. Add parser and resolver tests for all four generate commands, both batch
commands, shared flags, report-specific date rules, malformed input,
`--llm-debug-dir`, `--quiet`, output paths, and rejection of retired report
names.
2. Add assembled CLI tests for representative successful single and batch
invocations using injected offline boundaries. Verify exactly one executor
construction per action.
3. Cover pre-run errors with no invented run summary, successful and failed
JSON summaries, quiet-mode behavior, safe human status output, partial paths,
and omission of absent notification/debug fields.
4. Restore inspection tests for report listing and v1/v2 metadata, modules,
data packages, prior snapshots, and sources. Include failed v2 runs and v1
fixtures using historical report IDs.
5. Assert that routine output never contains rendered prompts, schema bodies,
data packages, generated bodies, endpoints, full parameters, credentials, or
secret-like dependency errors.
6. Keep tests at stable CLI/app boundaries; do not restore assertions about
private parser formatting or Scriptorium subprocess mechanics.
### Tests
Run:
```sh
go test ./internal/cli ./internal/app ./internal/state
go run ./cmd/weatherreporter --help
git diff --check
```
### Exit Gate
The supported CLI surface, summaries, quiet mode, partial failures, executor
composition, and v1/v2 inspection behavior have deterministic offline coverage.
## Stage 19: Finalize Documentation And Repository Verification
Status: Completed.
### Goal
Close the audit remediation, make roadmap lifecycle state truthful, and verify
the repository against the complete target contract.
### Work
1. Update `docs/roadmap/promptkit.md` from future tense and “unimplemented”
statuses to a completed roadmap record. Describe its old seven-report and
Scriptorium material explicitly as the pre-migration baseline rather than
current behavior.
2. Mark Stages 1219 and this implementation plan complete only after their
exit gates pass. Retain the concise completed-stage history unless the
documentation policy calls for archival in the same change.
3. Review canonical architecture, app, state, CLI, Promptkit integration,
operations, troubleshooting, configuration, and testing documentation
against the corrected implementation. Update only actual current-state
discrepancies; do not duplicate the roadmap.
4. Search current-state code, tests, examples, help, and non-roadmap
documentation for stale Scriptorium terms, retired reports, old artifact
fields, speculative-path descriptions, or claims of missing Promptkit
implementation.
5. Confirm examples contain no credentials or private infrastructure values
and load through config tests.
### Final Verification
Run:
```sh
gofmt -w <all changed Go files>
go mod tidy
go vet ./...
go test -count=1 ./...
go test -race ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Then verify explicitly:
- `go list -m gitea.maximumdirect.net/eric/promptkit` reports `v0.4.0`;
- no committed `go.work`, `replace`, secret fixture, or live-provider test
exists;
- all four prompts inspect at exact version `1.0.0`;
- no runtime prompt requests repair attempts;
- v1 fixtures remain inspectable and new runs write only v2;
- normal artifacts and output contain no sensitive prompt/debug content;
- failed-run metadata, execution artifacts, app results, batch items, and CLI
summaries contain exactly the paths actually reached;
- help exposes only Daily, Today, Tomorrow, Hourly, morning, and evening; and
- managed Markdown remains the only Distributor upload source.
### Exit Gate
Every migration and audit-remediation criterion is demonstrably satisfied,
the restored tests protect the consequential contracts, canonical
documentation describes the corrected implementation, and both roadmap
documents are marked complete.
## Open Questions
None. The roadmap and this completed plan record the decisions used for the
audit remediation.

516
docs/roadmap/promptkit.md Normal file
View File

@@ -0,0 +1,516 @@
# Promptkit Migration Roadmap
Status: Completed roadmap record.
## Purpose
This roadmap records the scope, decisions, and completed outcome of replacing
the external Scriptorium CLI integration with Promptkit. Canonical
documentation outside `docs/roadmap/` owns the implemented behavior.
## Pre-Migration Baseline
Status: Historical migration input.
Before the migration, Weatherreporter exposed seven report definitions, but
only four had complete prompt-backed report implementations:
- Daily Report: `weather.daily_generated_text`
- Today Report: `weather.today_generated_text`
- Tomorrow Report: `weather.tomorrow_generated_text`
- Hourly Report: `weather.hourly_generated_text`
The three-day, weekend, and storm commands and registry definitions had no
corresponding Scriptorium prompt or schema and never formed complete
operational report products. The `weather.daily_report` Scriptorium prompt was
legacy source material and was not selected by the registry.
The Scriptorium source corpus was retained temporarily under
`docs/roadmap/scriptorium/` as migration input. It contained the four
operational generated-text prompt definitions, their referenced content,
private response schemas, shared instructions, and the unused legacy Daily
Markdown prompt. The temporary corpus was removed after the runtime assets
were reconciled and embedded.
## Implemented End State
Status: Completed.
Weatherreporter pins
`gitea.maximumdirect.net/eric/promptkit` at `v0.4.0` and uses it as the
in-process engine for prompt inspection, prepared execution, provider calls,
and first-pass output validation.
The `scriptorium` executable, subprocess adapter, configuration, runtime
dependency, direct-Markdown execution path, and integration documentation have
been removed. The four operational reports continue to use structured
generated text followed by weatherreporter-owned validation and Markdown
templates.
The unfinished three-day, weekend, and storm reports are not implemented as
part of this migration. Their incomplete CLI, registry, documentation, and
generation declarations are removed from the implemented surface before the
migration is considered complete. Any future implementation of those products
requires separate roadmap scope, prompt and schema design, tests, and
documentation.
Weather selection, forecast derivation, valid periods, module construction,
Recent Changes, generated-text interpretation, Markdown templates, durable
state, inspection, output copies, and Distributor notification remain owned by
weatherreporter.
The four report prompts and private response schemas are versioned embedded
application assets. Operators configure Promptkit profiles without replacing
the report-owned corpus. One Promptkit engine is constructed per CLI
invocation and shared by every report in that invocation, including all
reports in a morning or evening batch.
Promptkit is isolated behind a weatherreporter-owned execution contract.
Promptkit request, result, validation, error, profile, backend, and provider
types do not leak into application orchestration, report definitions, domain
packages, CLI summaries, durable state contracts, or Distributor behavior.
## Goals
Status: Completed migration outcomes.
- Removed the Scriptorium runtime dependency and subprocess boundary.
- Migrated the four operational report prompts to Promptkit `v0.4.0`.
- Used prepared execution to persist preparation provenance before provider work
while executing the exact frozen snapshot.
- Validated report prompt and profile selections before weather collection when
the required information is available.
- Preserved deterministic module snapshots and structured Recent Changes.
- Preserved generated-text domain validation and repository-owned Markdown
rendering.
- Preserved context cancellation, actionable errors, secret redaction, and
inspectable failures.
- Improved durable prompt provenance with prompt, input, profile, model,
validation, usage, and timing metadata.
- Kept content-rich prompt and response diagnostics separate from routine
metadata and CLI output.
- Kept tests offline and deterministic through injected Promptkit model
clients and fixtures.
- Removed incomplete report declarations from the implemented product surface
rather than creating new report products during an integration migration.
## Non-Goals
Status: Completed migration constraints.
The completed migration did not:
- create prompts, schemas, templates, or completed products for three-day,
weekend, or storm reports;
- preserve the unused `weather.daily_report` legacy Markdown prompt as an
active runtime asset;
- preserve a direct-Markdown LLM generation mode;
- move meteorological selection, derivation, thresholds, or comparison logic
into prompts or Promptkit;
- send raw unbounded Weather API responses to the model;
- replace weatherreporter's generated-text domain validation or Markdown
template rendering;
- add a general workflow engine, provider plugin system, or arbitrary backend
registry;
- add automatic provider, validation, repair, or capacity retries;
- add concurrent report generation to the sequential batch workflow;
- expose Promptkit types as weatherreporter contracts;
- keep a production-selectable Scriptorium/Promptkit dual-run mode;
- require Promptkit eager source validation, structured generation errors, or
semantic execution-target fingerprints; or
- use an unpublished Promptkit commit, committed Go workspace, or committed
local module replacement.
## Locked Decisions
Status: Implemented migration decisions.
### Dependency And Upgrade Boundary
- The migration pins the tagged Promptkit `v0.4.0` release.
- Coordinated local development may temporarily use the sibling Promptkit
checkout, but committed module metadata must reference the tagged release.
- The adapter relies on the public root Promptkit package only.
- A future Promptkit upgrade requires explicit review of prepared-execution
lifecycle, prompt and profile inspection, prompt/profile/schema formats,
error identities, validation behavior, capacity behavior, and the outbound
provider contract.
- Promptkit's deferred eager source validation, structured generation errors,
and semantic execution-target fingerprints do not block this migration.
### Operational Report Scope
- The migration preserves these prompt IDs:
`weather.daily_generated_text`, `weather.today_generated_text`,
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`.
- Each operational report definition selects the exact embedded prompt version
`1.0.0`; execution does not rely on ambiguous single-version lookup.
- Morning and evening batch membership remains based on Today, Tomorrow, and
eligible future Daily reports.
- Three-day, weekend, and storm are removed from current CLI help, parsing,
report registry membership, tests that claim implemented generation, and
non-roadmap documentation.
- The future product concepts may remain under `docs/roadmap/`, but migration
verification does not invent outputs or compare nonexistent prompts.
### Application Boundary
- Promptkit remains an adapter boundary even though it runs in process.
- A weatherreporter-owned contract represents prompt identity, preparation,
execution, output, validation, usage, provenance, and neutral error
categories.
- The Promptkit adapter maps public Promptkit values into that contract.
- App orchestration and test fakes depend on the project-owned contract, not
Promptkit.
- Scriptorium-specific request, result, error, and generation-mode types are
removed rather than renamed and retained.
### Prompt And Schema Ownership
- Weatherreporter embeds the four operational prompt definitions, referenced
prompt content, shared prompt content, and private response schemas.
- Assets remain separate files rather than inline Go strings.
- The temporary corpus under `docs/roadmap/scriptorium/` is migration source
material, not the final runtime location.
- Weatherreporter's existing generated-text domain types, schemas, and
templates remain the canonical application contract. Imported Scriptorium
assets are reconciled with that contract rather than copied blindly or kept
as duplicate runtime schemas.
- The imported Daily schema's incorrect Today `$id` and title are corrected.
- `confidence` is handled consistently across each prompt, provider-facing
schema, generated-text domain type, and template. The existing optional
weatherreporter field remains supported unless a separate domain decision
removes it.
- Prompt input metadata identifies the serialized data package as YAML rather
than JSON.
- Imported `pipeline-weather/...` schema paths are replaced with paths valid
inside the embedded Promptkit schema source.
- Imported `repair_attempts: 2` values are removed or set to zero. The
migration does not rely on Promptkit's internal-only repair capability.
- The unused `weather.daily_report` prompt is not promoted into runtime assets.
- One centralized embedded prompt/schema source is sufficient; Weatherreporter
does not need Notarius's multi-module asset-flattening registry.
### Profiles, Backends, And Credentials
- Execution profiles remain operator-configurable rather than embedded report
policy.
- Each embedded operational prompt declares Promptkit's built-in
`gemini-flash-latest` profile as its default.
- `gemini-flash-latest` is intentionally a moving model alias. The execution
record captures the effective model identity, but operators who require a
pinned model must select an explicit external profile.
- Configuration supports at most one external profile source:
`promptkit.profile_file` or `promptkit.profile_dir`. The two fields are
mutually exclusive.
- A nonblank `promptkit.profile` is the explicit request profile for every
report in the invocation and takes precedence over each prompt's
`default_profile`. A blank value uses the prompt default.
- Promptkit's normal profile-source precedence remains intact: an external
matching profile takes precedence over an embedded built-in profile, and an
invalid matching external profile is an error rather than a reason to fall
back.
- Weatherreporter exposes Promptkit's conventional `local` backend through the
narrow `promptkit.local.endpoint` and
`promptkit.local.concurrency_limit` configuration fields. It does not expose
arbitrary backend registration.
- A configured local endpoint registers the engine-scoped `local` backend. An
operator-supplied external profile selects it with `backend: local` and owns
the model-specific settings; Weatherreporter does not invent a local model
profile.
- Local concurrency defaults to one. A value of zero means unlimited, matching
Promptkit, and a negative value is invalid. Queue capacity and general
backend parameters are not exposed.
- Credential values remain in environment variables or file-backed
environment secrets. Configuration contains only credential source names.
- Provider credentials never appear in logs, errors, CLI output, durable
metadata, preparation records, execution records, or debug summaries.
- Promptkit `InspectProfile` reports structural target and credential
requirements; Weatherreporter owns policy for checking configured
environment availability.
- Promptkit revalidates environment credentials at `RunPrepared`; a successful
preparation does not promise that execution-time credentials remain
available.
### Configuration Contract
The replacement configuration surface is:
```yaml
promptkit:
profile: ""
profile_file: ""
profile_dir: ""
timeout: 2m
local:
endpoint: ""
concurrency_limit: 1
```
- `timeout` remains the transport-wide provider-call safety cap.
- A blank local endpoint leaves the conventional local backend unregistered.
- Scriptorium's `binary`, `config_path`, and `extra_args` settings have no
Promptkit equivalents and are removed.
- Configuration validation rejects simultaneous `profile_file` and
`profile_dir` values, invalid local endpoints, negative concurrency, and
selected profiles that cannot resolve their backend.
### Engine Construction And Inspection
- One Promptkit engine is constructed per CLI invocation at the application
composition boundary.
- Single-report generation and every report in a batch use that same engine.
- Per-report orchestration does not construct a default engine.
- Promptkit backend capacity state and HTTP transport are shared consistently
for the invocation.
- Before collection, `InspectPrompt` checks every selected report's exact ID
and version, declared `data_package` input, default-profile metadata, prompt
hash availability, and declared output contract.
- `InspectPrompt` is a point-in-time structural check. It does not load a JSON
Schema, resolve a profile, or freeze later execution.
- Explicit profile overrides and relevant prompt defaults are checked with
`InspectProfile` before collection when application policy requires them.
- `InspectProfile` is also point-in-time and does not check credential values.
- Successful `PrepareExecution`, not inspection, is the per-run authority for
loaded schema, rendered content, frozen inputs, effective settings, and
durable execution provenance.
### Prompt Input
- Promptkit receives only the curated `data_package` produced by
`internal/promptinput`.
- Weatherreporter serializes the package once, atomically persists those exact
bytes, and supplies the same bytes with a Promptkit inline artifact.
- The managed data-package path may be supplied as non-secret provenance
through the inline artifact URI.
- Weatherreporter does not delegate unrestricted path loading to Promptkit's
default file artifact reader.
- Prompt inspection and adapter tests verify that `data_package` is required
and declared with the chosen YAML media type.
### Prepared Execution
- `Engine.PrepareExecution` replaces Scriptorium render preflight.
- Weatherreporter obtains `PreparedExecution.Details`, maps a safe subset into
its own preparation record, and persists that record before calling
`Engine.RunPrepared`.
- `RunPrepared` executes the frozen prompt, profile, schema, inputs, rendered
messages, target, and validation resources retained by the handle.
- Every acquired handle is followed immediately by `defer handle.Discard()`.
Discard is safe after execution and releases unused private execution state.
- Handles remain adapter-local, engine-bound, one-shot, in-process values.
They are never serialized, persisted, copied into app contracts, or treated
as restartable jobs.
- Preparation and execution use independent contexts. Execution receives the
active report workflow context.
- Capacity is not reserved during preparation. Capacity rejection can
therefore occur after a preparation record has been persisted.
- `RunPrepared` consumes the handle on success and every operational failure.
- Preparation details remain available from the adapter after execution or
discard, but rendered messages are not copied into routine durable state.
- Promptkit execution timing excludes preparation and consumer-held delay.
Weatherreporter records preparation timing and execution timing separately.
### Execution And Validation
- All four operational reports use Promptkit JSON Schema output validation.
- A completed Promptkit validation rejection returns a `RunResult`; the
adapter retains raw output and bounded validation details before failing the
report.
- An operational generation or validation error returns no partial
`RunResult`.
- Weatherreporter's `internal/generatedtext` validation remains the final
report-specific decode and domain boundary.
- Weatherreporter's `internal/reporttemplate` remains responsible for managed
Markdown rendering.
- Weatherreporter atomically persists Promptkit raw output and later artifacts
rather than asking Promptkit to choose managed filesystem paths.
- No Promptkit output-repair behavior is assumed or requested.
## Durable Artifacts And Observability
Status: Implemented design constraints.
Routine durable state retains useful non-secret provenance without persisting
full rendered prompts.
The preparation record contains:
- prompt ID and exact version;
- prompt definition hash;
- rendered prompt hash;
- input hashes;
- selected profile and backend identity;
- effective model identity;
- output contract summary;
- preparation start, end, and duration; and
- the path of the exact persisted data package.
The execution record and run metadata contain, when available:
- Promptkit run ID;
- prompt ID, version, and hashes;
- input hashes;
- selected profile, backend, and model identity;
- generated-content hash;
- token usage;
- execution start, end, and duration;
- validation status and bounded diagnostics; and
- paths of separately persisted raw output, normalized generated text, render
context, managed Markdown, and other artifacts reached by the workflow.
Provider endpoints, full effective model parameter maps, rendered messages,
schema bodies, data-package contents, and generated content do not belong in
routine metadata or CLI summaries.
Rendered messages and other content-rich preparation or response diagnostics
are available only when the operator supplies
`--llm-debug-dir <path>` to a single-report or batch command.
- There is no persistent YAML setting for debug capture.
- The debug root is validated or created before weather collection or provider
work. A requested destination that cannot be secured or written is an error.
- Artifacts are grouped beneath
`<path>/<report-id>/<valid-date>/<run-id>/`.
- Directories and files use owner-only permissions and atomic writes.
- Debug artifacts may contain rendered messages and content-rich preparation
or response diagnostics, but never credentials.
- The debug path appears in command output only when debug capture is enabled;
it is not added to routine durable metadata.
- Debug artifacts are not cache or comparison inputs. Their retention is owned
by the operator who selected the directory.
### Artifact Identities And Versions
Weatherreporter replaces Scriptorium-specific artifact identities rather than
reusing names whose meanings have changed:
- `PromptPreparationArtifact` uses schema version
`weatherreporter.prompt_preparation.v1`, is written as
`prompt_preparation.<runID>.json`, and is referenced by
`preparationPath`.
- `PromptExecutionArtifact` uses schema version
`weatherreporter.prompt_execution.v1`, is written as
`prompt_execution.<runID>.json`, and is referenced by `executionPath`.
- Run metadata advances to `weatherreporter.metadata.v2` and uses those new
path fields.
Preparation files remain beneath the existing configurable `preflight/`
directory, and execution files remain beneath the existing `snapshots/` tree.
The stable physical grouping limits deployment disruption without preserving
misleading Scriptorium-era filenames or field names. Raw generated output,
normalized generated text, render context, managed Markdown, and other
artifacts whose meanings have not changed retain their existing names and
locations.
Run inspection remains able to read `weatherreporter.metadata.v1` and its
legacy `preflightPath` and `generatedTextResultPath` references. New runs write
only the v2 metadata and new artifact names; Weatherreporter does not
dual-write deprecated aliases. CLI summary fields adopt `preparationPath` and
`executionPath` as an explicit, documented contract change.
## Failure Contract
Status: Implemented design constraints.
- A preparation failure produces a redacted weatherreporter-owned failure
receipt with report, RunID, prompt, stage, timing, and classified error
context. It does not fabricate Promptkit preparation details.
- An operational execution failure retains the successful preparation record
and adds a redacted execution failure receipt. No partial Promptkit result or
model output is invented.
- A Promptkit validation rejection retains the returned result, raw generated
output, validation details, and safe provenance before the report fails.
- A later generated-text decode, domain-validation, or template failure
retains every raw and validated artifact reached before that stage.
- Caller cancellation takes precedence when the active workflow context is
canceled.
- `promptkit.CapacityError` is recognized with `errors.As`; its backend ID is
copied into a weatherreporter-owned capacity error while
`ErrCapacityExceeded` remains the classification.
- Capacity rejection is an operational report failure, not invalid model
output, and does not trigger an automatic retry.
- Other Promptkit public error identities are translated into the narrow
weatherreporter error categories needed by CLI, metadata, and batch
behavior. Diagnostic prose is not parsed as a contract.
- Single-report commands return the classified failure with available
inspectable paths.
- Batch runs continue independent later reports under the existing batch
failure policy.
- Any future retry policy belongs to app orchestration, not the adapter.
## Compatibility Requirements
Status: Implemented design constraints.
- Daily, Today, Tomorrow, and Hourly report IDs, prompt IDs, valid periods,
artifact grouping, output names, and Distributor bundle behavior remain
stable.
- Morning and evening batch collection, planning, ordering, and continuation
behavior remains stable.
- Module snapshot and Recent Changes behavior remains deterministic.
- Promptkit receives only the existing curated prompt-input boundary.
- Managed Markdown remains the Distributor upload source.
- RunID lookup and inspection remain available for successful and failed runs.
- Existing managed paths remain stable where their meaning is unchanged.
Scriptorium-specific artifact names or schemas change when retaining them
would misrepresent the Promptkit contract.
- Existing v1 run metadata and referenced artifacts remain inspectable after
the migration. New runs use the v2 metadata and Promptkit-era artifact
identities without dual-writing deprecated aliases.
- Artifact or metadata schema changes are explicit, documented, and covered by
state and inspection tests.
- Prompt or generated content is not added to routine logs or CLI summaries.
- Tests do not require live providers or credentials.
- Removing incomplete three-day, weekend, and storm surfaces is documented as
correction of an unfinished product boundary, not as successful Promptkit
migration of those reports.
## Verification And Completion Criteria
Status: Completed and verified.
Completion was verified by the following outcomes:
- the four operational reports inspect, prepare, and execute through Promptkit
`v0.4.0` using embedded report-owned assets;
- every report uses exact prompt version `1.0.0`, requires the YAML
`data_package`, and declares the expected JSON Schema output contract;
- prepared execution persists a safe preparation record before provider work
and executes the same frozen snapshot;
- deterministic offline adapter and app tests cover success, preparation
failure, credential revalidation, capacity rejection, cancellation, timeout,
generation failure, Promptkit validation rejection, generated-text domain
failure, template failure, and handle discard;
- morning and evening batches construct one engine and preserve current
collection, planning, ordering, continuation, output, and notification
behavior;
- the temporary corpus has been reconciled into one runtime prompt/schema
source without duplicate provider-facing schemas;
- configuration examples load and contain no Scriptorium fields;
- CLI summaries and inspection commands expose the new project-owned artifact
contract without Promptkit types;
- Scriptorium code, configuration, tests, and runtime documentation have been
removed;
- incomplete three-day, weekend, and storm commands, registry entries, tests,
and current-behavior documentation have been removed or moved to roadmap
scope;
- non-roadmap documentation describes only the implemented Promptkit
integration;
- `go test ./...`, required focused or race-enabled checks, CLI help
validation, and `git diff --check` pass; and
- no committed `go.work`, local `replace`, live-provider test, or
secret-bearing fixture remains.
Fixture-based comparison with prior Scriptorium behavior is sufficient.
Production dual-run is not required because model calls are nondeterministic,
costly, and difficult to compare meaningfully.
## Decision Status
Status: Completed.
The roadmap has no remaining open product or architecture questions. Later
changes to this completed scope require new roadmap or decision-record scope
rather than implicit changes to this historical record.

View File

@@ -1,442 +1,179 @@
# Report Templates
## Purpose
This guide is for maintainers editing Weatherreporter's embedded Markdown
templates. Templates format already validated report inputs; they do not select
sources, derive weather facts, or validate generated prose. For those details,
see [Generated Text internals](internal/generatedtext.md) and [Report Template
internals](internal/reporttemplate.md).
This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports.
## Template Assets
Templates are Go `text/template` files. The implemented top-level templates
are:
Only the generated-text reports use repository-native Markdown templates.
Each report has one matching template ID, generated-text schema ID, and prompt
source:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
| Report | Template | Schema | Prompt ID and source |
| --- | --- | --- | --- |
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `internal/promptassets/assets/prompts/daily/` |
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `internal/promptassets/assets/prompts/today/` |
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `internal/promptassets/assets/prompts/tomorrow/` |
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
Shared named partials live under `internal/reporttemplate/templates/partials/`:
The matching schemas and Promptkit definitions are embedded by
`internal/promptassets`. The generated-text catalog pairs each schema ID with
its template ID; keep the matching prompt definition aligned with that pair.
- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly for the
combined Alerts and Risk Products section
- `daypart_forecast.md.tmpl`, used by Daily and Tomorrow
- `today_daypart_forecast.md.tmpl`, used by Today
- `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly
Shared partials are under `internal/reporttemplate/templates/partials/`:
Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text
validation, and artifact paths are handled before template rendering.
| Partial | Used by |
| --- | --- |
| `alert_digest.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
| `precipitation_timing.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
| `daypart_forecast.md.tmpl` | Daily and Tomorrow |
| `today_daypart_forecast.md.tmpl` | Today |
All shared partials are parsed whenever any top-level template is rendered. A
syntax error in a partial can therefore prevent every generated-text report
from rendering.
## Editing Rules
- Use Go `text/template` syntax.
- Keep templates focused on Markdown layout, headings, ordering, and simple
conditional display.
- Do not put weather derivation, source selection, or path construction logic in
templates.
- Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are registered.
- Named partials are invoked with `{{ template "name" . }}`. Pass the current
render context (`.`) unless the partial is intentionally designed for a
narrower value.
- Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
- Use Go `text/template` syntax and keep changes to Markdown structure,
ordering, and display conditions.
- Templates use `missingkey=error`; reference only documented fields and guard
optional module pointers with `with` or `if`.
- Prefer `.Modules` for deterministic display values. Do not add weather
calculations, source selection, or prompt-input shaping to a template.
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
in generated prose merely to compensate for a template change.
- When changing the generated-prose contract, update the matching prompt,
schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
`.Modules.DerivedDaypartSummaries`, which is a map.
## Hourly Context
The hourly template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
available when a template needs lower-level facts, but templates should still
avoid nontrivial derivation.
## Report
| Variable | Type | Description |
| --- | --- | --- |
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
| `.Report.Timezone` | string | Effective report timezone. |
## GeneratedText
These fields are written by Scriptorium as structured JSON, validated by
weatherreporter, and then inserted into the render context.
| Variable | Type | Description |
| --- | --- | --- |
| `.GeneratedText.Summary` | string | Required short prose summary. |
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
Example:
```gotemplate
{{ .GeneratedText.Summary }}
## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }}
```
## Tomorrow Context
The Tomorrow template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Tomorrow generated text uses the same `.GeneratedText.Summary`,
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow uses the shared `alert_digest`, `daypart_forecast`, and
`precipitation_timing` partials.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Daily Context
The Daily template receives the same five top-level values as Tomorrow, using
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Daily uses the shared `alert_digest`, `daypart_forecast`, and
`precipitation_timing` partials.
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`.
Daily modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Today Context
The Today template receives the same five top-level values as Tomorrow, using
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Today generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Today uses the `today_daypart_forecast` partial so elapsed or missing dayparts
can be omitted while Daily and Tomorrow keep their fallback row. It also uses
the shared `alert_digest` and `precipitation_timing` partials.
Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`.
Today modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Modules
`.Modules` exposes typed outputs from the same module pipeline used for the
prompt data package. Module fields are pointers because missing-data policy may
omit a stanza.
Templates render from rich module values, not from the curated YAML data
package. Some fields documented below are deterministic wording helpers for
Markdown templates and are intentionally omitted from data packages passed to
Scriptorium. The data package is a prompt input, while the render context is the
template surface.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
### Current Conditions
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
Example:
Minimal optional-value pattern:
```gotemplate
{{ with .Modules.CurrentConditions }}
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
{{ else }}
No current conditions available.
Current conditions are unavailable.
{{ end }}
```
### Hourly Forecast
Common period fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
Example:
Minimal list pattern:
```gotemplate
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}{{ end }}
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ end }}
```
### Precipitation Timing
## Registered Functions
Common fields:
Templates have these helpers in addition to Go template built-ins:
| Variable | Type | Description |
| Function | Accepts | Returns true when |
| --- | --- | --- |
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PrecipitationType` | string | Conservatively inferred precipitation type, such as `showers and thunderstorms`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].ExpectationPhrase` | string | Probability-based sentence used by precipitation timing templates. |
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
### Daypart Summaries
For example, the alert partial uses the first two functions to decide whether
to render the section:
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`.
```gotemplate
{{ if hasRelevantAlerts .Modules.AlertDigest }}
## Alert Digest
{{ end }}
```
The shared daypart partials render from these same `.Modules.Dayparts` values.
Edit `daypart_forecast.md.tmpl` for common Daily/Tomorrow wording, and edit
`today_daypart_forecast.md.tmpl` for Today-specific omission behavior.
## Render Context
Common rich daypart fields:
Every rendered template receives one typed context with these five top-level
fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
| Field | Purpose |
| --- | --- |
| `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| `.Collected` | Normalized upstream facts for advanced use. |
| `.Derived` | Shared calculated facts for advanced use. |
Template-only daypart helpers such as `TemperaturePhraseF`,
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
remain available here even though they are not serialized into data-package
YAML.
`.Collected` and `.Derived` are available for an exceptional display need, but
they are lower-level contracts. Keep reusable weather derivation in Go and use
the module surface for normal template work.
### Alert Digest
### Report Metadata
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
| `.Modules.AlertDigest.Relevant[].PeriodBegins` | string | Friendly local alert applicability start. |
| `.Modules.AlertDigest.Relevant[].PeriodEnds` | string | Friendly local alert applicability end. |
| `.Modules.AlertDigest.Relevant[].Instruction` | string | Alert instruction text, when provided. |
| `.Modules.AlertDigest.Relevant[].Description` | string | Alert description text, when provided. |
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
### SPC Outlooks And Discussion
Hourly additionally provides `.Report.LocationName` and
`.Report.ValidPeriodLabel`.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest` | []briefing.SPCConvectiveOutlookDigest | Curated categorical outlooks for the shared Alerts and Risk Products section. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].RiskLabel` | string | Sentence-style risk label for report rendering. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
Daily, Today, and Tomorrow additionally provide `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, and `.Report.ForecastDayName`. Their valid-period
field remains canonical timing data; use the supplied display labels instead
of formatting timestamps in a template.
### Area Forecast Discussion
### Validated GeneratedText Prose
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
GeneratedText is prose returned by Promptkit and validated before rendering.
It is not a source for deterministic weather facts.
### Weather Story
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
| --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Optional prose used by the precipitation partial when deterministic windows exist. |
| `.GeneratedText.Confidence` | `string` | `string` | Optional validated prose; the current templates do not render it. |
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
| `.Modules.WeatherStory.Title` | string | Story title. |
| `.Modules.WeatherStory.Description` | string | Story description. |
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
| `.Modules.WeatherStory.Order` | int | Source order. |
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
The JSON schema rejects unknown properties and defines the required fields, but
the schema body and validation behavior are documented in [Generated Text
internals](internal/generatedtext.md).
## Collected And Derived Facts
### Deterministic Module Values
The template also receives the full `facts.CollectedFacts` and
`facts.DerivedFacts` structs:
Module values are deterministic outputs built from collected and derived facts.
Module pointers can be nil when their source or policy permits omission.
- `.Collected` contains normalized source data and provenance from upstream
Weather API fetches.
- `.Derived` contains shared slices and calculations used across modules, such
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
filtering inputs.
| Module field | Available in |
| --- | --- |
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
| `.Modules.TodayPlanning` | Today |
| `.Modules.TomorrowPlanning` | Tomorrow |
These values are intentionally lower-level than `.Modules`. Use them when a
template needs a specific field that is not exposed by a module, but keep
calculation-heavy changes in Go.
The repository templates currently use the following nested display values.
They are the preferred surface for comparable edits:
## Validation
| Area | Values |
| --- | --- |
| Current conditions | `.TemperatureF`, `.ConditionText`, `.ConditionTextLower`, `.ApparentTemperatureF`, `.RelativeHumidityPercent`, `.WindDirectionText`, `.WindSpeedMph` |
| Hourly periods | `.Periods`, `.HourLabel`, `.Name`, `.TemperatureF`, `.TextDescription`, `.TextDescriptionLower`, `.MentionPrecipitation`, `.ProbabilityOfPrecipitationPercent` |
| Dayparts | `.Dayparts[].Key` and `.Dayparts[].Summary` fields `DisplayName`, `DominantCondition`, `DominantConditionDisplay`, `TemperatureTrend`, `TemperatureStartPhraseF`, `TemperatureEndPhraseF`, `TemperaturePeakPhraseF`, `TemperatureSteadyPhraseF`, `TemperaturePhraseF`, `MentionPrecipitation`, and `MaxPopPercent` |
| Precipitation timing | `.PrecipitationWindows`, plus each window's `PeriodBegins`, `PeriodBeginsHourLabel`, `PeriodEnds`, `PeriodEndsHourLabel`, `ExpectationPhrase`, `MaxPopPercent`, `MaxPopTime`, and `MaxPopHourLabel` |
| Alert digest | `.AlertDigest.Relevant` entries' `Event`, `Headline`, `PeriodBegins`, and `PeriodEnds` |
| SPC risk digest | `.SPCConvectiveOutlooks.RiskDigest` entries' `LabelText`, `RiskLabel`, `PeriodBegins`, and `PeriodEnds` |
After editing a template, run:
Other fields on these typed modules remain available when a template has a
well-defined display need. Their module contracts and weather derivation belong
to [Module contract internals](internal/module.md), [Module builder
internals](internal/briefing.md), and [Forecast derivation
internals](internal/forecast-derivation.md).
```bash
## Validate Changes
Run the focused checks after editing templates, partials, prompts, or schemas:
```sh
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
```
For a full check, run:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
templates through `internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`.
The render-context and template tests cover Daily, Today, Tomorrow, and Hourly
contexts. Run the repository-wide test suite before merging a broader change.

View File

@@ -1,481 +1,48 @@
# Weatherreporter Troubleshooting
# Troubleshooting
This guide lists recurring failures with likely causes, diagnostics, and safe
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
[Operations guide](operations.md) for normal usage.
Keep failed workspace artifacts in place. When a RunID is available, start
with `weatherreporter inspect metadata RUN_ID` and use the paths in its result.
## `weather_api.base_url is required`
## Prompt inspection or credentials fail before collection
Symptom: a generation command fails before collecting weather data.
A prompt/version, contract, selected profile, unsupported direct-key profile,
or required environment credential can fail before weather collection. Correct
the configured `promptkit` profile or profile source, confirm the exact
Promptkit asset is available, and supply any reported environment credential.
Do not add provider keys to YAML. See [configuration](config.md).
Likely cause: no Weather API base URL is configured.
## Preparation, capacity, or execution fails
Diagnostic:
A preparation failure occurs before provider work; an execution failure occurs
after preparation. Both leave safe provenance and metadata when reached. A
capacity error for one batch report does not retry that report or prevent later
independent reports. Inspect the preparation or execution path, correct the
profile/backend condition, and create a new run. See [operations](operations.md).
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
## Generated text fails validation
Safe fix: add `weather_api.base_url` to the config file, or pass the intended
config path with `--config`.
Raw generated output may be saved but Markdown is not rendered when the JSON
does not match the report schema. Correct the Promptkit prompt/profile behavior
or the matching schema and validator in source control; do not edit raw output
to treat it as validated. See [templates](templates.md).
Relevant docs: [Configuration reference](config.md).
## Debug capture fails
## `weather_api.base_url must be an absolute URL`
`--llm-debug-dir` must be an absolute secure directory outside workspace state.
A debug-write failure stops the affected report to avoid continuing without the
requested diagnostic. Repair the named path's ownership or permissions, then
rerun. Treat capture files as sensitive. See [operations](operations.md).
Symptom: config loading fails with a base URL validation error.
## Weather, state, output, or notification fails
Likely cause: `weather_api.base_url` is missing a scheme or host.
Collection errors precede planning. Later filesystem, output-copy, template,
or Distributor errors retain the reached safe paths in the summary. Repair only
the reported endpoint or path, leave successful managed reports intact, and
rerun the affected report or batch. A batch notification is intentionally
skipped when any report item fails.
Diagnostic: inspect the configured value in the file passed to `--config`.
## Secrets cannot be loaded
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
Relevant docs: [Configuration reference](config.md).
## Invalid Timezone
Symptom: config loading fails with `weather_api.timezone` context, or a CLI
timezone override fails.
Likely cause: `weather_api.timezone` or `--tz` is not recognized.
Diagnostic:
```sh
weatherreporter generate daily --tz America/Chicago --date 2026-05-29
```
Safe fix: use an accepted timezone value, such as an IANA timezone name,
`Chicago`, `Stl`, a US timezone abbreviation, or a UTC offset.
Relevant docs: [Configuration reference](config.md).
## Storm Command Rejects Time Bounds
Symptom: `generate storm` fails with `requires --start`, `requires --end`, or
`requires --end after --start`.
Likely cause: the manual event window is missing or invalid.
Diagnostic:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Safe fix: provide both bounds. Use `YYYY-MM-DDTHH:MM` in the configured
timezone, or RFC3339 timestamps with explicit offsets.
Relevant docs: [CLI reference](cli.md).
## Weather API Fetch Fails
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
Likely cause: the configured Weather API endpoint is unreachable, returned a
non-2xx response, or returned an invalid response envelope.
Diagnostic:
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
service response. The adapter fetches `/observations`, `/conditions/current`,
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
Relevant docs: [Configuration reference](config.md).
## Hourly Forecast Is Missing
Symptom: generation fails with hourly forecast context, such as missing hourly
data or an hourly forecast containing no periods.
Likely cause: hourly forecast data is required for generated reports.
Diagnostic: check the Weather API response for `/forecast/hourly`.
Safe fix: restore hourly forecast data at the Weather API. Missing-source
policy cannot make hourly optional.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## Source Warnings Appear
Symptom: generation succeeds, but metadata or `inspect sources` shows source
warnings.
Likely cause: an optional source was missing or malformed under a warning
missing-source policy.
Diagnostic:
```sh
weatherreporter inspect sources RUN_ID
weatherreporter inspect metadata RUN_ID
```
Safe fix: inspect the warning `source`, `code`, `message`, and `endpoint`. Fix
the upstream optional source, or intentionally change the relevant
`missing_source` policy.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## `scriptorium` Is Not Found Or Cannot Start
Symptom: generation fails with `run scriptorium render` or `run scriptorium`
and an executable or OS error.
Likely cause: the configured Scriptorium binary is unavailable or not
executable.
Diagnostic: check `scriptorium.binary` in config and run the same binary outside
`weatherreporter`.
Safe fix: install Scriptorium, update `scriptorium.binary`, or fix executable
permissions.
Relevant docs: [Configuration reference](config.md),
[Scriptorium integration](integrations/scriptorium.md).
## Render Preflight Fails
Symptom: generation fails with `scriptorium render exited with code ...`.
Likely cause: Scriptorium rejected the prompt, config, profile, or
`data_package` input before report generation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
Then read the preflight path from metadata. It contains captured stdout, stderr,
exit code, and command.
Safe fix: fix the Scriptorium configuration, prompt ID, profile, or data package
input indicated by stderr.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Scriptorium Run Fails
Symptom: generation fails with `scriptorium run exited with code ...`.
Likely cause: Scriptorium failed during report generation or validation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
If metadata includes a rendered report path, inspect that report as well. A
nonzero run can still leave a managed report artifact.
Safe fix: use the captured stderr and data package to fix the Scriptorium
prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Generated Text Validation Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
decode, unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
Then inspect the generated-text raw path recorded in metadata, if present.
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
writes the expected structured JSON for the report.
Relevant docs: [Operations guide](operations.md),
[Generated Text internals](internal/generatedtext.md),
[Scriptorium integration](integrations/scriptorium.md).
## Template Rendering Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
parsing or execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
If metadata records generated-text and render-context paths, inspect those
artifacts along with the template named by the report definition.
Safe fix: update the embedded template or render-context builder so the
template uses the implemented typed context.
Relevant docs: [Report Templates](templates.md),
[Report Template internals](internal/reporttemplate.md).
## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero.
Likely cause: weather collection failed before planning, or at least one
planned report failed after planning succeeded, or every report succeeded but
the top-level batch distributor notification failed.
Diagnostic: if stdout contains a JSON summary, inspect each failed report item
and the top-level `notification` object. Stderr includes one
`batchNotification` line when batch notification is attempted, skipped, or
fails. If no summary was emitted, inspect the command error; configuration,
Weather API collection, or batch validation failed before any report artifacts
were created.
Safe fix: for collection failures, fix the configuration or upstream Weather
API availability and rerun the batch. For report failures, use the failed
report's artifact paths from the summary, then inspect metadata, sources,
module snapshot, and data package for that RunID. For a batch notification
failure, inspect the notification artifact path from the top-level
`notification.path`.
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
## Batch Upload Skipped
Symptom: a batch JSON summary contains
`"notification":{"status":"skipped","reason":"one or more reports failed"}`.
Likely cause: at least one planned report failed, so weatherreporter did not
call distributor for the batch.
Diagnostic: inspect the failed report items in the batch JSON summary and the
matching stderr report lines. A skipped batch notification has no distributor
run ID and no notification artifact path.
Safe fix: fix the report-generation failure first, then rerun the batch. The
batch upload is all-or-nothing.
Relevant docs: [Operations guide](operations.md).
## Batch Upload Fails
Symptom: every report item in a batch summary is succeeded, but the batch
returns nonzero and the top-level notification has `status: "failed"`.
Likely cause: the distributor upload was rejected, the distributor service was
unavailable, status polling reached a terminal distributor failure, or
weatherreporter rejected the batch file mapping before upload.
Diagnostic: inspect `notification.error`, `notification.pipelineId`,
`notification.bundleId`, `notification.idempotencyKey`, and
`notification.path` in stdout. Then inspect the notification artifact; it
records included report source paths, bundle paths, upload status, distributor
run status, status lookup error, and raw status report JSON when available.
Safe fix: fix the endpoint, token, distributor pipeline, batch identity
templates, or report path templates indicated by the error, then rerun the
batch. Individual report artifacts from the failed batch notification remain
available and do not need to be regenerated for diagnosis.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Duplicate Batch Bundle Path
Symptom: a batch returns nonzero with duplicate bundle path context before a
distributor run ID is accepted.
Likely cause: report-specific distributor path templates rendered the same
bundle-relative path for two included reports in the same batch.
Diagnostic: inspect the error in stdout or stderr. The validation error
includes the duplicate bundle path plus the report IDs, RunIDs, and managed
source paths involved.
Safe fix: configure a per-report distributor path override so every report in a
batch renders a unique path. Include values such as `{artifact_group}`,
`{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Source Conflict
Symptom: distributor accepts or rejects an upload with conflict context for a
source, destination, digest, or idempotency key.
Likely cause: the rendered bundle ID or idempotency key does not match the
intended producer identity. A bundle ID identifies the logical source stream;
an idempotency key identifies a retry of the same upload request.
Diagnostic: inspect the report notification artifact linked from metadata or
the batch notification artifact linked from the top-level notification path.
Compare the rendered pipeline ID, bundle ID, idempotency key, included source
paths, and bundle paths with `notify.distributor.*` templates and distributor
pipeline state.
Safe fix: keep bundle ID templates stable for the source stream that should be
updated, and keep idempotency keys stable only for retries of the same generated
content. Do not reuse one idempotency key for different report or batch
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Invalid Secrets Directory
Symptom: config loading fails with `read secrets directory`, `secret file`, or
environment variable name context.
Likely cause: `secrets.directory` points to a missing directory or contains an
invalid entry. Secret entries must be regular files directly under the
configured directory, and file basenames must match
`[A-Za-z_][A-Za-z0-9_]*`.
Diagnostic: list the configured directory and inspect entry names and file
types. Do not print secret file contents.
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
filenames, and ensure the weatherreporter process can read each secret file.
Relevant docs: [Configuration reference](config.md).
## Distributor Token Is Missing
Symptom: notification fails with a message that the distributor token
environment variable is not set.
Likely cause: `notify.distributor.enabled` is true, but the environment
variable named by `notify.distributor.token_env` was not populated directly or
through `secrets.directory`.
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
file exists under `secrets.directory` or that the process environment includes
the variable. Do not print the token value.
Safe fix: create a readable secret file whose basename matches `token_env`, or
set the environment variable through the service manager.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Upload Conflict
Symptom: notification fails with idempotency conflict context.
Likely cause: the same idempotency key was reused for different bundle content
within the same distributor token and pipeline. By default the bundle ID is a
stable report-stream identity and the idempotency key appends RunID.
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
and idempotency context. For batch commands, use the top-level notification
object rather than per-report notification fields. Compare the configured
templates with the report RunID or batch RunID and report path.
Also inspect the notification artifact linked from metadata or from the
top-level batch notification path. It records the rendered pipeline ID, bundle
ID, idempotency key, upload result, distributor run status, status error, and
raw run report JSON when available.
Safe fix: keep idempotency templates stable for retries of the same generated
report, but do not reuse the same rendered key for different generated report
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Upload Rejected
Symptom: notification fails with distributor upload rejection, HTTP status, or
bundle validation context.
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
ID, idempotency key, source file, or one of the rendered bundle paths.
Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError` or the top-level batch notification `error`. Confirm
`notify.distributor.endpoint`,
`notify.distributor.pipeline_id_template`,
report-specific distributor paths, and token configuration. Token
values are redacted from weatherreporter errors.
If the upload was accepted but destination output did not change, inspect the
notification artifact's `runStatus.report`. Distributor actions such as
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
the destination handled the uploaded bundle.
Safe fix: fix the endpoint, token, templates, or distributor-side upload
configuration. The weatherreporter upload source is the managed Markdown report,
not `--out` or `--out-dir` copies.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Unavailable
Symptom: notification fails with network, timeout, or service unavailable
context.
Likely cause: the configured distributor endpoint is unreachable, slow, or
temporarily unavailable.
Diagnostic: check network access from the weatherreporter host to
`notify.distributor.endpoint`. For batch runs, inspect the top-level
notification object and the artifact linked by `notification.path`.
Safe fix: restore distributor service availability and rerun the affected
report or batch. Stable idempotency keys make retrying the same generated report
safe unless the distributor reports a conflict.
Relevant docs: [Operations guide](operations.md).
## Unknown RunID
Symptom: an inspect command fails with `metadata for run id ... was not found`.
Likely cause: the RunID is mistyped or the command is reading a different
workspace.
Diagnostic:
```sh
weatherreporter inspect reports --config ./config.yml --limit 20
```
Safe fix: copy a RunID from `inspect reports`, or use the same `--config` and
workspace that generated the report.
Relevant docs: [Operations guide](operations.md).
## Workspace Path Error
Symptom: startup or inspection fails with workspace path validation or
filesystem read/write context.
Likely cause: a workspace subdirectory is absolute, escapes `workspace.root`, or
the process cannot read or write the configured path.
Diagnostic: review `workspace.root`, `workspace.snapshots_dir`,
`workspace.reports_dir`, `workspace.data_packages_dir`, and
`workspace.preflight_dir`.
Safe fix: keep workspace subdirectories relative to `workspace.root`, and grant
the process appropriate filesystem permissions.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
Secret files must be regular non-symlink files directly beneath
`secrets.directory` with valid environment-variable basenames. Correct the
reported file or directory without placing secret values in YAML.

View File

@@ -1,7 +1,7 @@
weather_api:
base_url: https://weather.api.example.com/
timeout: 15s
precision: 1
precision: 0
units: us
timezone: "America/Chicago"
format: json
@@ -35,9 +35,10 @@ missing_source:
sources:
alerts: none
scriptorium:
binary: scriptorium
promptkit:
timeout: 2m
local:
concurrency_limit: 1
workspace:
root: workspace

10
go.mod
View File

@@ -4,4 +4,12 @@ go 1.26
require gopkg.in/yaml.v3 v3.0.1
require gitea.maximumdirect.net/eric/distributor v0.5.0
require (
gitea.maximumdirect.net/eric/distributor v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.4.0
)
require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/text v0.14.0 // indirect
)

8
go.sum
View File

@@ -1,5 +1,7 @@
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
gitea.maximumdirect.net/eric/promptkit v0.4.0 h1:WHRQEt3BVBAR7hQePBaGtNXpzrs59mlr/42nQzwgOz4=
gitea.maximumdirect.net/eric/promptkit v0.4.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
@@ -36,16 +38,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,322 @@
// Package promptkitadapter implements promptexec with Promptkit.
package promptkitadapter
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
// Config selects the Promptkit sources and optional local backend for one engine.
type Config struct {
ProfileDirectory string
ProfileFile string
LocalEndpoint string
LocalConcurrencyLimit int
Timeout time.Duration
}
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
type Adapter struct {
engine *promptkit.Engine
}
var _ promptexec.Executor = (*Adapter)(nil)
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
func New(config Config) (*Adapter, error) {
return newAdapter(config)
}
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
if config.ProfileDirectory != "" && config.ProfileFile != "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
}
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
}
options := []promptkit.Option{
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
}
if config.ProfileFile != "" {
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
}
if config.LocalEndpoint != "" {
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
}
options = append(options, additionalOptions...)
engine, err := promptkit.NewEngine(promptkit.Config{
ProfileDir: config.ProfileDirectory,
Timeout: config.Timeout,
}, options...)
if err != nil {
return nil, classifyConfigurationError(err)
}
return &Adapter{engine: engine}, nil
}
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
return newAdapter(config, promptkit.WithLLMClient(client))
}
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
if adapter == nil || adapter.engine == nil {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
if err != nil {
return promptexec.PromptInspection{}, classifyError(err)
}
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
for index, input := range inspection.Inputs {
inputs[index] = promptexec.InputDefinition{
Name: input.Name,
Required: input.Required,
ContentType: input.ContentType,
Description: input.Description,
}
}
return promptexec.PromptInspection{
PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash,
DefaultProfileID: inspection.DefaultProfileID,
Inputs: inputs,
Output: outputContract(inspection.OutputContract),
}, nil
}
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
if adapter == nil || adapter.engine == nil {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
if err != nil {
return promptexec.ProfileInspection{}, classifyError(err)
}
return promptexec.ProfileInspection{
ProfileID: inspection.ProfileID,
BackendID: inspection.EffectiveModelParams.BackendID,
ModelName: inspection.EffectiveModelParams.Model,
CredentialRequired: inspection.APIKeyRequired,
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
}, nil
}
// Execute prepares one exact inline data package, invokes prepared after a
// successful preparation, and then runs the same opaque prepared handle.
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if adapter == nil || adapter.engine == nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
PromptID: request.PromptID,
PromptVersion: request.PromptVersion,
ProfileID: request.ProfileID,
Inputs: map[string]promptkit.ArtifactRef{
"data_package": promptkit.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
},
})
if err != nil {
return nil, classifyError(err)
}
defer prepared.Discard()
details := prepared.Details()
preparation, debug := preparationValues(details, request.DataPackagePath, request.CaptureDebug)
if preparedCallback != nil {
if err := preparedCallback(preparation, debug); err != nil {
return nil, err
}
}
result, err := adapter.engine.RunPrepared(ctx, prepared)
if err != nil {
return nil, classifyError(err)
}
return executionValue(result, request.DataPackagePath, request.CaptureDebug), nil
}
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
return promptexec.OutputContract{
Format: string(value.Format),
ValidationMode: string(value.ValidationMode),
SchemaPath: value.SchemaPath,
}
}
func preparationValues(value promptkit.PreparedRun, dataPackagePath string, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
preparation := promptexec.Preparation{
PromptID: value.PromptID,
PromptVersion: value.PromptVersion,
PromptHash: value.PromptHash,
RenderedPromptHash: value.RenderedPromptHash,
InputHashes: copyInputHashes(value.InputHashes),
ProfileID: value.SelectedProfileID,
BackendID: value.SelectedBackendID,
ModelName: value.EffectiveModelParams.Model,
Output: outputContract(value.OutputContract),
StartedAt: value.StartTime,
EndedAt: value.EndTime,
Duration: time.Duration(value.DurationMS) * time.Millisecond,
DataPackagePath: dataPackagePath,
}
if !captureDebug {
return preparation, nil
}
debug := &promptexec.PreparationDebug{
RenderedMessages: renderedMessages(value.Messages),
Endpoint: value.EffectiveModelParams.Endpoint,
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
}
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
}
return preparation, debug
}
func executionValue(value *promptkit.RunResult, dataPackagePath string, captureDebug bool) *promptexec.Execution {
if value == nil {
return nil
}
validation := promptexec.NewValidation(
promptexec.ValidationStatus(value.Validation.Status),
string(value.Validation.Mode),
value.Validation.SchemaPath,
value.Validation.Errors,
)
execution := &promptexec.Execution{
RunID: value.RunID,
PromptID: value.PromptID,
PromptVersion: value.PromptVersion,
PromptHash: value.PromptHash,
RenderedPromptHash: value.RenderedPromptHash,
InputHashes: copyInputHashes(value.InputHashes),
ProfileID: value.SelectedProfileID,
BackendID: value.SelectedBackendID,
ModelName: value.ModelName,
GeneratedHash: value.Artifact.Hash,
Usage: promptexec.TokenUsage{
PromptTokens: value.Usage.PromptTokens,
CompletionTokens: value.Usage.CompletionTokens,
TotalTokens: value.Usage.TotalTokens,
CachedTokens: value.Usage.CachedTokens,
CacheWriteTokens: value.Usage.CacheWriteTokens,
},
StartedAt: value.StartTime,
EndedAt: value.EndTime,
Duration: value.Duration,
Validation: validation,
DataPackagePath: dataPackagePath,
RawOutput: []byte(value.RawOutput),
}
if captureDebug {
execution.Debug = &promptexec.ExecutionDebug{
RawOutput: append([]byte(nil), value.RawOutput...),
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
}
}
return execution
}
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
messages := make([]promptexec.RenderedMessage, len(values))
for index, value := range values {
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
}
return messages
}
func copyInputHashes(values map[string]string) map[string]string {
if values == nil {
return nil
}
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
parameters := struct {
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
TopP float64 `json:"top_p"`
TimeoutSeconds int `json:"timeout_seconds"`
ServiceTier string `json:"service_tier"`
ReasoningEffort string `json:"reasoning_effort"`
ExtraParams map[string]any `json:"extra_params"`
}{
Temperature: value.Temperature,
MaxTokens: value.MaxTokens,
TopP: value.TopP,
TimeoutSeconds: value.TimeoutSeconds,
ServiceTier: value.ServiceTier,
ReasoningEffort: value.ReasoningEffort,
ExtraParams: value.ExtraParams,
}
data, _ := json.Marshal(parameters)
return data
}
func classifyConfigurationError(err error) error {
if err == nil {
return nil
}
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
}
func classifyError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) {
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
}
if errors.Is(err, context.DeadlineExceeded) {
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
}
var capacityError *promptkit.CapacityError
if errors.As(err, &capacityError) {
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
}
switch {
case errors.Is(err, promptkit.ErrInvalidConfig):
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
case errors.Is(err, promptkit.ErrPromptNotFound):
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
case errors.Is(err, promptkit.ErrPromptLoad):
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
case errors.Is(err, promptkit.ErrProfileNotFound):
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
case errors.Is(err, promptkit.ErrProfileLoad):
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
case errors.Is(err, promptkit.ErrArtifactLoad):
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
case errors.Is(err, promptkit.ErrPromptRender):
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
case errors.Is(err, promptkit.ErrCapacityExceeded):
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
case errors.Is(err, promptkit.ErrLLMGenerate):
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
case errors.Is(err, promptkit.ErrValidation):
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
default:
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
}
}

View File

@@ -0,0 +1,384 @@
package promptkitadapter
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type fakeClient struct {
mu sync.Mutex
response *promptkit.GenerateResponse
err error
calls int
requests []promptkit.GenerateRequest
block bool
}
type recordingReader struct {
ref promptkit.ArtifactRef
}
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
reader.ref = ref
return &promptkit.Artifact{
Name: "data_package",
ContentType: "application/yaml",
Body: []byte(ref.Body),
URI: ref.URI,
Hash: "input-hash",
}, nil
}
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
client.mu.Lock()
client.calls++
client.requests = append(client.requests, request)
block := client.block
response := client.response
err := client.err
client.mu.Unlock()
if block {
<-ctx.Done()
return nil, ctx.Err()
}
return response, err
}
func (client *fakeClient) callCount() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.calls
}
func (client *fakeClient) request() promptkit.GenerateRequest {
client.mu.Lock()
defer client.mu.Unlock()
return client.requests[0]
}
func TestInspectPromptAndProfile(t *testing.T) {
adapter := newTestAdapter(t, &fakeClient{})
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
t.Fatalf("inspection = %#v", inspection)
}
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
t.Fatalf("inputs = %#v", inspection.Inputs)
}
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
t.Fatalf("output = %#v", inspection.Output)
}
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
if err != nil {
t.Fatalf("InspectProfile() error = %v", err)
}
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
t.Fatalf("profile = %#v", profile)
}
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
t.Fatalf("profile leaks endpoint: %#v", profile)
}
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
if err != nil {
t.Fatalf("InspectProfile(builtin) error = %v", err)
}
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
}
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
callbackCalls := 0
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
callbackCalls++
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.DataPackagePath != request.DataPackagePath || preparation.ModelName != "test-model" {
t.Fatalf("preparation = %#v", preparation)
}
if debug != nil {
t.Fatalf("debug = %#v, want nil", debug)
}
if client.callCount() != 0 {
t.Fatal("provider called before preparation callback")
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if callbackCalls != 1 || client.callCount() != 1 {
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
}
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content || result.DataPackagePath != request.DataPackagePath {
t.Fatalf("result = %#v", result)
}
if result.Debug != nil {
t.Fatalf("debug = %#v, want nil", result.Debug)
}
providerRequest := client.request()
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
t.Fatalf("provider target = %#v", providerRequest.Target)
}
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
}
}
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
client := &fakeClient{response: validResponse()}
reader := &recordingReader{}
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
request := testExecuteRequest()
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != request.DataPackagePath || reader.ref.Body != string(request.DataPackage) {
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
}
}
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
var preparationDebug *promptexec.PreparationDebug
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
preparationDebug = debug
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
t.Fatalf("preparation debug = %#v", preparationDebug)
}
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
t.Fatalf("execution debug = %#v", result.Debug)
}
}
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
callbackError := errors.New("save preparation")
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
return callbackError
})
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
}
}
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
adapter := newTestAdapter(t, client)
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
t.Fatalf("result = %#v", result)
}
}
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
tests := []struct {
name string
client *fakeClient
context func() (context.Context, context.CancelFunc)
category promptexec.ErrorCategory
}{
{
name: "generation",
client: &fakeClient{err: errors.New("provider response body")},
context: func() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
},
category: promptexec.Generation,
},
{
name: "canceled",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, func() {}
},
category: promptexec.Canceled,
},
{
name: "deadline",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Nanosecond)
},
category: promptexec.DeadlineExceeded,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := newTestAdapter(t, test.client)
ctx, cancel := test.context()
defer cancel()
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
}
if strings.Contains(err.Error(), "provider response body") {
t.Fatalf("error leaks provider detail: %v", err)
}
})
}
}
func TestClassifyPromptkitErrors(t *testing.T) {
tests := []struct {
err error
category promptexec.ErrorCategory
}{
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
{promptkit.ErrPromptRender, promptexec.PromptRender},
{promptkit.ErrLLMGenerate, promptexec.Generation},
{promptkit.ErrValidation, promptexec.OperationalValidation},
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
}
for _, test := range tests {
t.Run(string(test.category), func(t *testing.T) {
got := classifyError(test.err)
if promptexec.CategoryOf(got) != test.category {
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
}
})
}
}
func TestNewValidatesConfiguration(t *testing.T) {
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("profile source error = %v", err)
}
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local concurrency error = %v", err)
}
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local endpoint error = %v", err)
}
}
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
profiles := testProfileDirectory(t, `id: local-profile
backend: local
model: local-model
`)
adapter, err := newAdapterForTest(Config{
ProfileDirectory: profiles,
LocalEndpoint: "https://local.example/v1",
LocalConcurrencyLimit: 1,
}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest(local) error = %v", err)
}
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
t.Fatalf("local profile/error = %#v/%v", profile, err)
}
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
t.Fatalf("capacity classification = %v", got)
}
credentialProfiles := testProfileDirectory(t, `id: credential-profile
endpoint: https://profile.example/v1
model: test-model
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
`)
client := &fakeClient{response: validResponse()}
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
if err != nil {
t.Fatalf("newAdapterForTest(credential) error = %v", err)
}
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
}
request := testExecuteRequest()
request.ProfileID = "credential-profile"
result, err := credentialAdapter.Execute(context.Background(), request, nil)
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
}
}
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
return newTestAdapterWithOptions(t, client)
}
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
t.Helper()
profiles := testProfileDirectory(t, `id: test-profile
endpoint: https://profile.example/v1
model: test-model
temperature: 0.2
max_tokens: 300
top_p: 1
timeout_seconds: 30
`)
options = append(options, promptkit.WithLLMClient(client))
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
return adapter
}
func testProfileDirectory(t *testing.T, profile string) string {
t.Helper()
profiles := t.TempDir()
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return profiles
}
func testExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.daily_generated_text",
PromptVersion: "1.0.0",
ProfileID: "test-profile",
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
DataPackagePath: "data-packages/daily/data_package.yaml",
}
}
func validResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}

View File

@@ -1,338 +0,0 @@
// Package scriptorium adapts the external scriptorium CLI.
package scriptorium
import (
"context"
"fmt"
"io"
"os/exec"
"time"
)
const maxCapturedOutputBytes = 1024 * 1024
type CommandRunner interface {
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
}
type CommandResult struct {
Stdout []byte
Stderr []byte
StdoutTruncated bool
StderrTruncated bool
ExitCode int
}
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
runCtx := ctx
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
}
defer cancel()
cmd := exec.CommandContext(runCtx, name, args...)
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
err := cmd.Run()
result := CommandResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
StdoutTruncated: stdout.Truncated(),
StderrTruncated: stderr.Truncated(),
ExitCode: 0,
}
if err == nil {
return result, nil
}
if runCtx.Err() != nil {
return result, runCtx.Err()
}
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
return result, nil
}
return result, err
}
type Runner struct {
Binary string
ConfigPath string
Profile string
Timeout time.Duration
ExtraArgs []string
Commands CommandRunner
}
type RenderRequest struct {
PromptID string
DataPackagePath string
}
type RunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult 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"`
}
type RunResult 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"`
OutputPath string `json:"outputPath"`
}
type StructuredRunResult 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"`
OutputPath string `json:"outputPath"`
}
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
execution, err := r.execute(ctx, r.renderArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err)
}
result := &RenderResult{
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,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}, "run scriptorium", "scriptorium run")
if err != nil {
if result == nil {
return nil, err
}
return result.runResult(), err
}
return result.runResult(), nil
}
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}, "run scriptorium structured output", "scriptorium structured run")
if err != nil {
if result == nil {
return nil, err
}
return result.structuredRunResult(), err
}
return result.structuredRunResult(), nil
}
func (result outputRunResult) runResult() *RunResult {
return &RunResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}
}
func (result outputRunResult) structuredRunResult() *StructuredRunResult {
return &StructuredRunResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}
}
type execution struct {
binary string
args []string
result CommandResult
}
type outputRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type outputRunResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}))
if err != nil {
return nil, fmt.Errorf("%s: %w", executeContext, err)
}
result := &outputRunResult{
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("%s exited with code %d: %s", exitContext, execution.result.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
result, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return execution{}, err
}
return execution{binary: binary, args: args, result: result}, nil
}
func (e execution) argv() []string {
return append([]string{e.binary}, e.args...)
}
func (r Runner) renderArgs(req RenderRequest) []string {
args := []string{"render"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--format", "json",
)
args = append(args, r.ExtraArgs...)
return args
}
func (r Runner) runArgs(req RunRequest) []string {
args := []string{"run"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--out", req.OutputPath,
)
args = append(args, r.ExtraArgs...)
return args
}
type limitedBuffer struct {
data []byte
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
b.truncated = true
return len(p), nil
}
remaining := b.limit - len(b.data)
if remaining <= 0 {
b.truncated = true
return len(p), nil
}
if len(p) > remaining {
b.data = append(b.data, p[:remaining]...)
b.truncated = true
return len(p), nil
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
return append([]byte{}, b.data...)
}
func (b *limitedBuffer) Truncated() bool {
return b.truncated
}
var _ io.Writer = (*limitedBuffer)(nil)

View File

@@ -1,544 +0,0 @@
package scriptorium
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"time"
)
func TestRenderConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: time.Minute,
Commands: commands,
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
wantArgs := []string{
"render",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml",
"--format", "json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
}
func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stderr: []byte("missing input"),
ExitCode: 1,
},
},
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
})
if err == nil {
t.Fatal("Render() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Render() result = nil, want captured result")
}
if result.ExitCode != 1 {
t.Fatalf("ExitCode = %d, want 1", result.ExitCode)
}
if !strings.Contains(err.Error(), "missing input") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestRunConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 45 * time.Second,
Commands: commands,
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 45*time.Second {
t.Fatalf("timeout = %s, want 45s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.OutputPath != "/tmp/daily.md" {
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
}
}
func TestRunReturnsResultForValidationExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte("# Daily Report\n"),
Stderr: []byte("validation failed"),
ExitCode: 2,
},
},
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err == nil {
t.Fatal("Run() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Run() result = nil, want captured result")
}
if result.ExitCode != 2 {
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
}
if !strings.Contains(err.Error(), "validation failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
Stderr: []byte("wrote generated text"),
StdoutTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 30 * time.Second,
Commands: commands,
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/generated_text_raw.hourly.json",
})
if err != nil {
t.Fatalf("StructuredRun() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/data_package.hourly.yaml",
"--out", "/tmp/generated_text_raw.hourly.json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
if containsArg(commands.args, disallowed) {
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
}
}
if commands.timeout != 30*time.Second {
t.Fatalf("timeout = %s, want 30s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result)
}
if result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
}
}
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte(`{"summary":"partial"}`),
Stderr: []byte("structured output failed"),
ExitCode: 3,
},
},
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/generated_text_raw.hourly.json",
})
if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("StructuredRun() result = nil, want captured result")
}
if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
}
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("result = %#v, want captured result fields", result)
}
if !strings.Contains(err.Error(), "structured output failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
type commonResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
tests := []struct {
name string
run func(Runner) (*commonResult, error)
}{
{
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
},
{
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte("captured stdout"),
Stderr: []byte("captured stderr"),
StdoutTruncated: true,
StderrTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 15 * time.Second,
Commands: commands,
}
result, err := test.run(runner)
if err != nil {
t.Fatalf("%s error = %v", test.name, err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/report.md",
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 15*time.Second {
t.Fatalf("timeout = %s, want 15s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("Command = %#v, want full argv", result.Command)
}
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" {
t.Fatalf("captured output = %q/%q, want stdout/stderr", result.Stdout, result.Stderr)
}
if !result.StdoutTruncated || !result.StderrTruncated {
t.Fatalf("truncation flags = %t/%t, want both true", result.StdoutTruncated, result.StderrTruncated)
}
if result.ExitCode != 0 || result.OutputPath != "/tmp/report.md" {
t.Fatalf("result = %#v, want exit 0 and output path", result)
}
})
}
}
func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) {
type commonResult struct {
Stdout string
Stderr string
StderrTruncated bool
ExitCode int
OutputPath string
}
tests := []struct {
name string
run func(Runner) (*commonResult, error)
wantErr string
}{
{
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
wantErr: "scriptorium run exited with code 7: captured stderr",
},
{
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
wantErr: "scriptorium structured run exited with code 7: captured stderr",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{result: CommandResult{
Stdout: []byte("captured stdout"),
Stderr: []byte("captured stderr"),
StderrTruncated: true,
ExitCode: 7,
}},
}
result, err := test.run(runner)
if err == nil {
t.Fatalf("%s error = nil, want nonzero exit error", test.name)
}
if result == nil {
t.Fatalf("%s result = nil, want captured result", test.name)
}
if err.Error() != test.wantErr {
t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
}
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated {
t.Fatalf("captured result = %#v, want stdout/stderr/truncation", result)
}
if result.ExitCode != 7 || result.OutputPath != "/tmp/report.md" {
t.Fatalf("result = %#v, want exit 7 and output path", result)
}
})
}
}
func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
run func(Runner, string, string, string) error
}{
{
name: "Run",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: promptID,
DataPackagePath: dataPackagePath,
OutputPath: outputPath,
})
if result != nil {
return fmt.Errorf("result = %#v, want nil", result)
}
return err
},
},
{
name: "StructuredRun",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: promptID,
DataPackagePath: dataPackagePath,
OutputPath: outputPath,
})
if result != nil {
return fmt.Errorf("result = %#v, want nil", result)
}
return err
},
},
}
cases := []struct {
name string
promptID string
dataPackagePath string
outputPath string
want string
}{
{
name: "prompt id",
dataPackagePath: "/tmp/data_package.yaml",
outputPath: "/tmp/report.md",
want: "prompt id is required",
},
{
name: "data package path",
promptID: "weather.markdown_report",
outputPath: "/tmp/report.md",
want: "data package path is required",
},
{
name: "output path",
promptID: "weather.markdown_report",
dataPackagePath: "/tmp/data_package.yaml",
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
commands := &fakeCommands{}
err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath)
if err == nil {
t.Fatalf("%s error = nil, want validation error", test.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s error = %v, want %q", test.name, err, tc.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
})
}
}
type fakeCommands struct {
name string
args []string
timeout time.Duration
result CommandResult
err error
calls int
}
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name
f.args = append([]string{}, args...)
f.timeout = timeout
return f.result, f.err
}
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}

View File

@@ -24,6 +24,12 @@ import (
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second
)
type Client struct {
@@ -35,6 +41,12 @@ type Client struct {
precision int
missingSource config.MissingSourceConfig
now func() time.Time
warmupEndpoint string
warmupAttempts int
warmupDelay time.Duration
fetchAttempts int
fetchRetryDelay time.Duration
}
type Option func(*Client)
@@ -80,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
Default: cfg.MissingSource.Default,
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
now: time.Now,
warmupEndpoint: defaultWarmupEndpoint,
warmupAttempts: defaultWarmupAttempts,
warmupDelay: defaultWarmupDelay,
fetchAttempts: defaultFetchAttempts,
fetchRetryDelay: defaultFetchRetryDelay,
}
for _, opt := range opts {
opt(client)
@@ -89,6 +106,10 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
}
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := c.warmup(ctx); err != nil {
return nil, err
}
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
@@ -397,24 +418,9 @@ type envelope struct {
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
return nil, weatherdata.Source{}, err
}
var env envelope
@@ -440,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) error {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
lastErr = err
} else {
return nil
}
if attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
attempts := positiveAttemptCount(c.fetchAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
}
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
if err == nil {
return reqURL, body, nil
}
lastErr = err
lastRetryable = isRetryableRequestError(err)
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
}
}
if lastRetryable {
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
return nil, nil, lastErr
}
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
err = fmt.Errorf("fetch %s: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err}
}
return reqURL, nil, err
}
return reqURL, body, nil
}
type retryableRequestError struct {
err error
}
func (e retryableRequestError) Error() string {
return e.err.Error()
}
func (e retryableRequestError) Unwrap() error {
return e.err
}
func isRetryableRequestError(err error) bool {
_, ok := err.(retryableRequestError)
return ok
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return ctx.Err()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func positiveAttemptCount(attempts int) int {
if attempts < 1 {
return 1
}
return attempts
}
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}

View File

@@ -80,8 +80,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
if len(requested) != len(wantPaths)+1 {
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
}
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
}
for _, want := range wantPaths {
if !containsPath(requested, want) {
@@ -132,9 +135,15 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
t.Fatalf("request %q missing units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
continue
}
if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") {
if !strings.Contains(rawURL, "precision=0") {
t.Fatalf("request %q missing precision=0", rawURL)
}
}
}
}
@@ -186,7 +195,7 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
func TestHTTPErrorIsActionable(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
@@ -194,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
if err == nil {
t.Fatal("FetchBundle() error = nil, want HTTP error")
}
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
}
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
var requested []string
var warmupCalls int
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
warmupCalls++
if warmupCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("vpn waking up"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Current == nil {
t.Fatal("Current = nil, want successful fetch after warmup retry")
}
if warmupCalls != 3 {
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
}
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
}
}
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup failure")
}
if !strings.Contains(err.Error(), "warm up weather API") ||
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
!strings.Contains(err.Error(), "2 attempts") ||
!strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if hourlyCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Hourly == nil {
t.Fatal("Hourly = nil, want successful fetch after retry")
}
if hourlyCalls != 2 {
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
}
}
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`not-json`))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want envelope decode error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestRequiredHourlyForecast(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
@@ -212,6 +346,9 @@ func TestRequiredHourlyForecast(t *testing.T) {
if !strings.Contains(err.Error(), "hourly forecast data") {
t.Fatalf("error = %q, want hourly context", err.Error())
}
if got := countPath(requested, "/forecast/hourly"); got != 1 {
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
}
}
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
@@ -420,6 +557,43 @@ func TestContextCancellation(t *testing.T) {
}
}
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
var cancel context.CancelFunc
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if cancel != nil {
cancel()
}
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
client.fetchRetryDelay = time.Hour
ctx, cancelFunc := context.WithCancel(context.Background())
cancel = cancelFunc
defer cancelFunc()
start := time.Now()
_, err := client.FetchBundle(ctx)
elapsed := time.Since(start)
if err == nil {
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
}
if !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("error = %q, want context cancellation", err.Error())
}
if elapsed > time.Second {
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
}
}
func TestHTTPTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
@@ -432,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
_, err = client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want timeout error")
}
if !strings.Contains(err.Error(), "/observations") {
if !strings.Contains(err.Error(), defaultWarmupEndpoint) {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
@@ -464,8 +640,9 @@ func TestSaveBundle(t *testing.T) {
}
type handlerOverride struct {
status int
body string
status int
body string
handler http.HandlerFunc
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
@@ -485,6 +662,10 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
*requested = append(*requested, r.URL.String())
}
if override, ok := overrides[r.URL.Path]; ok {
if override.handler != nil {
override.handler(w, r)
return
}
w.WriteHeader(override.status)
_, _ = w.Write([]byte(override.body))
return
@@ -510,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
return client
}
@@ -532,6 +715,16 @@ func containsPath(requested []string, path string) bool {
return false
}
func countPath(requested []string, path string) int {
var count int
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
count++
}
}
return count
}
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
t.Helper()
for _, source := range sources {

View File

@@ -3,13 +3,11 @@ package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
@@ -17,8 +15,8 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
@@ -33,9 +31,6 @@ const (
ReportToday ReportKind = ReportKind(report.CommandNameToday)
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
)
type BatchKind string
@@ -46,26 +41,28 @@ const (
)
type GenerateRequest struct {
Config config.Config
Report ReportKind
OutputPath string
Now time.Time
Date time.Time
StormStart time.Time
StormEnd time.Time
Collector Collector
Notifier Notifier
Config config.Config
Report ReportKind
OutputPath string
LLMDebugDir string
Now time.Time
Date time.Time
Collector Collector
Notifier Notifier
Executor promptexec.Executor
Store state.Store
}
type BatchRequest struct {
Config config.Config
Batch BatchKind
Now time.Time
OutputDir string
Collector Collector
Renderer Renderer
Store state.Store
Notifier Notifier
Config config.Config
Batch BatchKind
Now time.Time
OutputDir string
LLMDebugDir string
Collector Collector
Executor promptexec.Executor
Store state.Store
Notifier Notifier
}
type FetchBundleRequest struct {
@@ -83,38 +80,25 @@ type ReportFacts struct {
Derived facts.DerivedFacts
}
type ReportRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
Collection collect.Result
Renderer Renderer
Store state.Store
Notifier Notifier
noNotify bool
}
type ReportResult struct {
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreparationPath string
ExecutionPath string
LLMDebugPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
GeneratedTextRawPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
}
type BatchResult struct {
@@ -162,7 +146,9 @@ type BatchReportResult struct {
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
PreparationPath string `json:"preparationPath,omitempty"`
ExecutionPath string `json:"executionPath,omitempty"`
LLMDebugPath string `json:"llmDebugPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
@@ -202,12 +188,6 @@ func batchReportFailures(result *BatchResult) int {
return failures
}
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
}
type Collector interface {
Run(context.Context, collect.Request) (*collect.Result, error)
}
@@ -277,24 +257,33 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if now.IsZero() {
now = time.Now()
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
resolved, err := ResolveGenerate(req, now)
if err != nil {
return nil, err
}
if resolved.Definition.Generated {
return GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier,
})
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
return nil, fmt.Errorf("generate is not implemented")
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
Resolved: resolved,
Executor: req.Executor,
Promptkit: req.Config.Promptkit,
})
if err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
return generatePromptReport(ctx, promptReportRequest{
GenerateRequest: req,
Resolved: resolved,
Collection: *collection,
Inspection: inspection,
DebugWriter: debugWriter,
})
}
func RunBatch(ctx context.Context, req BatchRequest) error {
@@ -316,6 +305,22 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
candidates, err := batchInspectionCandidates(req, now)
if err != nil {
return nil, err
}
inspections, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
Resolved: candidates,
Executor: req.Executor,
Promptkit: req.Config.Promptkit,
})
if err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
@@ -335,58 +340,33 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, planned := range plannedReports {
resolved := planned.Resolved
item := batchReportResult(planned)
if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata
}
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: outputPath,
Collection: *collection,
Renderer: req.Renderer,
Store: store,
Notifier: req.Notifier,
noNotify: true,
reportResult, err := generatePromptReport(ctx, promptReportRequest{
GenerateRequest: GenerateRequest{
Config: req.Config,
OutputPath: outputPath,
Notifier: req.Notifier,
Executor: req.Executor,
Store: store,
},
Resolved: resolved,
Collection: *collection,
Inspection: inspections[resolved.Definition.ID],
DebugWriter: debugWriter,
noNotify: true,
})
if reportResult != nil {
copyBatchReportPaths(&item, reportResult)
}
if err != nil {
item.Status = "failed"
item.Error = err.Error()
var notificationErr *NotificationError
if errors.As(err, &notificationErr) {
item.NotificationStatus = "failed"
item.NotificationError = notificationErr.Error()
item.NotificationPipelineID = notificationErr.Request.PipelineID
if paths, pathErr := store.Paths(resolved); pathErr == nil {
item.NotificationPath = paths.Notification
}
}
result.Failed++
} else {
item.Status = "succeeded"
item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
item.NotificationPath = reportResult.NotificationPath
if reportResult.Notification != nil {
item.NotificationStatus = reportResult.Notification.Status
item.NotificationRunID = reportResult.Notification.RunID
item.NotificationPipelineID = reportResult.Notification.PipelineID
}
result.Succeeded++
}
result.Reports = append(result.Reports, item)
@@ -405,6 +385,51 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
return nil, fmt.Errorf("run is not implemented")
}
func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
item.DataPackagePath = result.DataPackagePath
item.PreparationPath = result.PreparationPath
item.ExecutionPath = result.ExecutionPath
item.LLMDebugPath = result.LLMDebugPath
item.ReportPath = result.ReportPath
item.OutputPath = result.OutputPath
item.MetadataPath = result.MetadataPath
item.NotificationPath = result.NotificationPath
if result.Notification != nil {
item.NotificationStatus = result.Notification.Status
item.NotificationRunID = result.Notification.RunID
item.NotificationPipelineID = result.Notification.PipelineID
}
}
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
ids := []report.ID{report.Tomorrow, report.Daily}
if req.Batch == BatchMorning {
ids = []report.ID{report.Today, report.Tomorrow, report.Daily}
}
date := timeutil.LocalDate(now, location).AddDate(0, 0, 2)
candidates := make([]report.Resolved, 0, len(ids))
for _, id := range ids {
resolveReq := report.ResolveRequest{Now: now, Location: location}
if id == report.Daily {
resolveReq.Date = date
}
resolved, err := registry.Resolve(id, resolveReq)
if err != nil {
return nil, err
}
candidates = append(candidates, resolved)
}
return candidates, nil
}
func batchReportResult(planned plannedBatchReport) BatchReportResult {
resolved := planned.Resolved
metadata := resolved.Metadata()
@@ -446,11 +471,9 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
return report.Resolved{}, err
}
return registry.Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
StormStart: req.StormStart,
StormEnd: req.StormEnd,
Now: now,
Location: location,
Date: req.Date,
})
}
@@ -505,391 +528,13 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
return bundle, nil
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil {
return nil, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config,
Resolved: req.Resolved,
}, bundle)
if err != nil {
return nil, err
}
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
Config: req.Config,
Resolved: req.Resolved,
}, reportFacts)
if err != nil {
return nil, err
}
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
if err != nil {
return nil, err
}
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil {
return nil, err
}
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recentChanges,
})
if err != nil {
return nil, err
}
dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
if err != nil {
return nil, err
}
metadata.DataPackagePath = dataPackagePath
renderer := req.Renderer
if renderer == nil {
renderer = scriptorium.Runner{
Binary: req.Config.Scriptorium.Binary,
ConfigPath: req.Config.Scriptorium.ConfigPath,
Profile: req.Config.Scriptorium.Profile,
Timeout: req.Config.Scriptorium.Timeout,
ExtraArgs: req.Config.Scriptorium.ExtraArgs,
}
}
renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
})
preflightPath := paths.Preflight
if renderResult != nil {
var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil {
return nil, err
}
}
metadata.PreflightPath = preflightPath
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if renderErr != nil {
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
return nil, renderErr
}
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
reportFacts: reportFacts,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
OutputPath: reportPath,
})
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: store,
Resolved: req.Resolved,
Metadata: metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
GenerationErr: runErr,
noNotify: req.noNotify,
})
if err != nil {
if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), nil
}
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
reportFacts ReportFacts
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
}
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if runErr != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
}
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
}
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
rendered, err := handler.Render(renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: req.store,
Resolved: req.Resolved,
Metadata: req.metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
noNotify: req.noNotify,
})
if err != nil {
if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil
}
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
return result.OutputPath == "" &&
result.NotificationPath == "" &&
result.MetadataPath == "" &&
result.Metadata.RunID == "" &&
result.Notification == nil
}
type reportResultRequest struct {
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
}
func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: req.reportPath,
OutputPath: req.finalized.OutputPath,
NotificationPath: req.finalized.NotificationPath,
Metadata: req.finalized.Metadata,
MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
RunResult: req.runResult,
StructuredRunResult: req.structuredRunResult,
GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextResultPath: req.generatedTextResultPath,
GeneratedTextPath: req.generatedTextPath,
RenderContextPath: req.renderContextPath,
Notification: req.finalized.Notification,
}
}
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
MetadataPath string
ExecutionArtifact *state.PromptExecutionArtifact
ManagedReportPath string
OutputPath string
Notifier Notifier
@@ -912,28 +557,35 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
}
if req.ExecutionArtifact == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("prompt execution artifact is required for report %q", req.Resolved.Definition.ID)
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
outputPath := req.ManagedReportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
result := finalizeRenderedReportResult{Metadata: req.Metadata, MetadataPath: req.MetadataPath}
if req.OutputPath != "" && req.GenerationErr == nil {
if req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
return finalizeRenderedReportResult{}, err
return result, err
}
result.OutputPath = req.OutputPath
if err := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
paths.OutputPath = req.OutputPath
}); err != nil {
return result, err
}
} else {
result.OutputPath = req.OutputPath
}
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return finalizeRenderedReportResult{}, err
}
result := finalizeRenderedReportResult{
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
return result, err
}
result.Metadata = metadata
result.MetadataPath = metadataPath
if req.GenerationErr != nil {
return result, req.GenerationErr
}
@@ -943,14 +595,21 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
result.NotificationPath = notificationPath
result.Notification = notification
metadata.NotificationPath = notificationPath
result.Metadata = metadata
if saveErr := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
paths.NotificationPath = notificationPath
}); saveErr != nil {
return result, saveErr
}
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return finalizeRenderedReportResult{}, saveErr
return result, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
result.NotificationPath = notificationPath
}
result.Notification = notification
if err != nil {
@@ -1046,9 +705,6 @@ func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resol
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err
}
if resolved.Definition.ID == report.Storm {
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
}
return values, nil
}
@@ -1359,29 +1015,11 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
switch reportID {
case report.Daily, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds)
case report.Weekend:
return changes.CompareWeekend(previous, current, thresholds)
default:
return nil, nil
}
}
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
if result == nil {
return state.PreflightArtifact{}
}
return state.PreflightArtifact{
Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
}
}
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
package app
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
tests := []struct {
name string
batch BatchKind
now string
wantPrompts int
}{
{name: "morning", batch: BatchMorning, now: "2026-05-29T08:00:00-05:00", wantPrompts: 3},
{name: "evening", batch: BatchEvening, now: "2026-05-29T18:00:00-05:00", wantPrompts: 2},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
now := mustParse(test.now)
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now}
candidates, err := batchInspectionCandidates(req, now)
if err != nil {
t.Fatalf("batchInspectionCandidates() error = %v", err)
}
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
}, prompts: map[string]promptexec.PromptInspection{}}
for _, candidate := range candidates {
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
}
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
return nil, errors.New("collection reached")
})
req.Executor = executor
req.Collector = collector
_, err = RunBatchDetailed(context.Background(), req)
if err == nil || err.Error() != "collection reached" {
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
}
if len(executor.promptRequests) != test.wantPrompts || len(executor.profileRequests) != 1 {
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
}
})
}
}
func TestCopyBatchReportPathsLeavesUnreachedPathsEmpty(t *testing.T) {
item := BatchReportResult{}
copyBatchReportPaths(&item, &ReportResult{
DataPackagePath: "/runs/daily/data_package.yaml",
PreparationPath: "/runs/daily/preparation.json",
})
data, err := json.Marshal(item)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "notificationPath"} {
if strings.Contains(text, omitted) {
t.Fatalf("batch item includes unreached field %q:\n%s", omitted, text)
}
}
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
t.Fatalf("batch item omits reached paths:\n%s", text)
}
}
type collectorFunc func(context.Context, collect.Request) (*collect.Result, error)
func (f collectorFunc) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
return f(ctx, req)
}
var _ Collector = collectorFunc(nil)

View File

@@ -55,19 +55,6 @@ func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
}
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
for _, item := range planned {
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
}
}
}
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)

View File

@@ -0,0 +1,500 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type assembledBatchExecutor struct {
definitions map[string]report.Definition
promptRequests []string
profileRequests []string
executeRequests []promptexec.ExecuteRequest
failures map[int]error
active int
maxActive int
}
func newAssembledBatchExecutor() *assembledBatchExecutor {
definitions := make(map[string]report.Definition)
for _, definition := range report.DefaultRegistry().All() {
definitions[definition.PromptID] = definition
}
return &assembledBatchExecutor{definitions: definitions, failures: make(map[int]error)}
}
func (e *assembledBatchExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
e.promptRequests = append(e.promptRequests, id+"@"+version)
definition, ok := e.definitions[id]
if !ok || definition.PromptVersion != version {
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
}
return validPromptInspection(definition), nil
}
func (e *assembledBatchExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
e.profileRequests = append(e.profileRequests, id)
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
}
func (e *assembledBatchExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
call := len(e.executeRequests)
e.executeRequests = append(e.executeRequests, req)
e.active++
if e.active > e.maxActive {
e.maxActive = e.active
}
defer func() { e.active-- }()
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
preparation := promptexec.Preparation{
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
}
if err := callback(preparation, nil); err != nil {
return nil, err
}
if err := e.failures[call]; err != nil {
return nil, err
}
definition := e.definitions[req.PromptID]
return &promptexec.Execution{
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath,
RawOutput: []byte(generatedTextForPrompt(req.PromptID)),
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
}, nil
}
type assembledBatchNotifier struct {
reportRequests []NotificationRequest
batchRequests []batchNotificationRequest
batchResult *NotificationResult
batchErr error
}
func (n *assembledBatchNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
n.reportRequests = append(n.reportRequests, req)
return nil, errors.New("per-report notification must be suppressed")
}
func (n *assembledBatchNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) {
n.batchRequests = append(n.batchRequests, req)
if n.batchErr != nil {
return nil, n.batchErr
}
if n.batchResult != nil {
result := *n.batchResult
if result.PipelineID == "" {
result.PipelineID = req.PipelineID
}
if result.BundleID == "" {
result.BundleID = req.BundleID
}
if result.IdempotencyKey == "" {
result.IdempotencyKey = req.IdempotencyKey
}
return &result, nil
}
return &NotificationResult{
RunID: "batch-notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
}, nil
}
func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
tests := []struct {
name string
batch BatchKind
now time.Time
wantIDs []report.ID
wantCopies []string
}{
{name: "morning", batch: BatchMorning, now: workflowTime("2026-05-29T08:00:00-05:00"), wantIDs: []report.ID{report.Today, report.Tomorrow, report.Daily}, wantCopies: []string{"today.md", "tomorrow.md", "daily-2026-05-31.md"}},
{name: "evening", batch: BatchEvening, now: workflowTime("2026-05-29T18:00:00-05:00"), wantIDs: []report.ID{report.Tomorrow, report.Daily}, wantCopies: []string{"tomorrow.md", "daily-2026-05-31.md"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := assembledBatchConfig(t, false)
bundle := assembledBatchBundle(t, "2026-05-31")
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
executor := newAssembledBatchExecutor()
outputDir := filepath.Join(t.TempDir(), "output")
debugRoot := filepath.Join(t.TempDir(), "debug")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot,
Collector: collector, Executor: executor,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if collector.calls != 1 || result.Total != len(test.wantIDs) || result.Succeeded != len(test.wantIDs) || result.Failed != 0 {
t.Fatalf("collection/summary = %d/%d/%d/%d", collector.calls, result.Total, result.Succeeded, result.Failed)
}
if len(executor.executeRequests) != len(test.wantIDs) || executor.maxActive != 1 {
t.Fatalf("executor calls/max active = %d/%d, want %d/1", len(executor.executeRequests), executor.maxActive, len(test.wantIDs))
}
if len(executor.profileRequests) != 1 {
t.Fatalf("profile inspections = %#v, want one shared profile inspection", executor.profileRequests)
}
for index, item := range result.Reports {
if item.ReportID != test.wantIDs[index] || item.Status != "succeeded" {
t.Fatalf("report %d = %s/%s, want %s/succeeded", index, item.ReportID, item.Status, test.wantIDs[index])
}
if executor.executeRequests[index].PromptID != item.PromptID {
t.Fatalf("execution %d prompt = %q, want item prompt %q", index, executor.executeRequests[index].PromptID, item.PromptID)
}
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.ReportPath == "" || item.OutputPath == "" || item.MetadataPath == "" {
t.Fatalf("successful report paths = %#v", item)
}
assertBatchItemMatchesMetadata(t, item)
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index])
}
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
managed, readErr := os.ReadFile(item.ReportPath)
if readErr != nil {
t.Fatalf("read managed report: %v", readErr)
}
copied, readErr := os.ReadFile(item.OutputPath)
if readErr != nil || !bytes.Equal(managed, copied) {
t.Fatalf("output copy mismatch/error = %v", readErr)
}
}
})
}
}
func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) {
cfg := assembledBatchConfig(t, true)
bundle := assembledBatchBundle(t, "2026-05-31")
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
executor := newAssembledBatchExecutor()
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
notifier := &assembledBatchNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
Collector: collector, Executor: executor, Notifier: notifier,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if collector.calls != 1 || len(executor.executeRequests) != 3 || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("collection/execution/summary = %d/%d/%d/%d/%d", collector.calls, len(executor.executeRequests), result.Total, result.Succeeded, result.Failed)
}
if len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 || result.Notification == nil || result.Notification.Status != "skipped" {
t.Fatalf("notification state = reports %d batches %d result %#v", len(notifier.reportRequests), len(notifier.batchRequests), result.Notification)
}
for index, item := range result.Reports {
if index == 1 {
if item.ReportID != report.Tomorrow || item.Status != "failed" || !strings.Contains(item.Error, string(promptexec.Capacity)) {
t.Fatalf("failed item = %#v", item)
}
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.MetadataPath == "" || item.ReportPath != "" || item.OutputPath != "" {
t.Fatalf("failed item reached paths = %#v", item)
}
assertBatchItemMatchesMetadata(t, item)
continue
}
if item.Status != "succeeded" || item.ReportPath == "" || item.OutputPath == "" {
t.Fatalf("continued item %d = %#v", index, item)
}
}
}
func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
cfg := assembledBatchConfig(t, false)
bundle := assembledBatchBundle(t, "2026-05-31")
notifier := &assembledBatchNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
})
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
}
})
t.Run("batch disabled", func(t *testing.T) {
cfg := assembledBatchConfig(t, true)
cfg.Notify.Distributor.Batch.Enabled = false
bundle := assembledBatchBundle(t, "2026-05-31")
notifier := &assembledBatchNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
})
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
}
})
t.Run("all success", func(t *testing.T) {
cfg := assembledBatchConfig(t, true)
bundle := assembledBatchBundle(t, "2026-05-31")
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
RunID: "batch-notification-run", Status: "succeeded", UploadStatus: "accepted",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}}
outputDir := filepath.Join(t.TempDir(), "output")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir,
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
}
managedPaths := make(map[string]struct{}, len(result.Reports))
for _, item := range result.Reports {
managedPaths[item.ReportPath] = struct{}{}
if item.NotificationPath != "" {
t.Fatalf("report item contains per-report notification path: %#v", item)
}
}
request := notifier.batchRequests[0]
if len(request.IncludedReports) != len(result.Reports) {
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
}
for _, file := range request.Files {
if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
t.Fatalf("notification file = %#v, want managed Markdown source", file)
}
}
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-notification-run" || artifact.RunStatus == nil || len(artifact.Reports) != len(result.Reports) {
t.Fatalf("notification artifact = %#v", artifact)
}
})
t.Run("upload failure", func(t *testing.T) {
cfg := assembledBatchConfig(t, true)
bundle := assembledBatchBundle(t, "2026-05-31")
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" || result.Notification.Path == "" {
t.Fatalf("result = %#v, want successful reports and failed notification", result)
}
for _, item := range result.Reports {
if item.Status != "succeeded" {
t.Fatalf("report item = %#v, want success despite notification failure", item)
}
}
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
t.Fatalf("notification artifact = %#v", artifact)
}
})
t.Run("status report", func(t *testing.T) {
cfg := assembledBatchConfig(t, true)
bundle := assembledBatchBundle(t, "2026-05-31")
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
RunID: "batch-notification-run", Status: "accepted", UploadStatus: "accepted",
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
})
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
t.Fatalf("result/error = %#v/%v", result, err)
}
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) {
t.Fatalf("notification artifact = %#v", artifact)
}
})
}
func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) {
cfg := assembledBatchConfig(t, false)
bundle := assembledBatchBundle(t, "2026-05-31", "2026-06-01")
outputDir := filepath.Join(t.TempDir(), "output")
debugRoot := filepath.Join(t.TempDir(), "debug")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot,
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
})
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
t.Fatalf("result/error = %#v/%v", result, err)
}
seenRuns := make(map[string]struct{})
seenDebug := make(map[string]struct{})
dailyDates := make(map[string]BatchReportResult)
for _, item := range result.Reports {
if _, exists := seenRuns[item.RunID]; exists {
t.Fatalf("duplicate run ID %q", item.RunID)
}
seenRuns[item.RunID] = struct{}{}
if _, exists := seenDebug[item.LLMDebugPath]; exists {
t.Fatalf("duplicate debug path %q", item.LLMDebugPath)
}
seenDebug[item.LLMDebugPath] = struct{}{}
if item.ReportID == report.Daily {
date := item.ValidPeriod.Start.In(mustLoadTestLocation(t, "America/Chicago")).Format("2006-01-02")
dailyDates[date] = item
}
}
for _, date := range []string{"2026-05-31", "2026-06-01"} {
item, ok := dailyDates[date]
if !ok {
t.Fatalf("daily items = %#v, want %s", dailyDates, date)
}
if !strings.HasSuffix(item.RunID, "_daily_"+date) || item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") {
t.Fatalf("daily identity/output = %q/%q", item.RunID, item.OutputPath)
}
wantDebugPrefix := filepath.Join(debugRoot, "daily", date, item.RunID)
if item.LLMDebugPath != wantDebugPrefix {
t.Fatalf("daily debug path = %q, want %q", item.LLMDebugPath, wantDebugPrefix)
}
assertBatchPathsExist(t, filepath.Join(item.LLMDebugPath, "preparation.json"), filepath.Join(item.LLMDebugPath, "execution.json"))
}
}
func TestRunBatchDetailedUsesPriorSnapshotsForPromptPackages(t *testing.T) {
cfg := assembledBatchConfig(t, false)
firstBundle := assembledBatchBundle(t, "2026-05-31")
setWorkflowTemperatures(&firstBundle, 45)
first, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}}, Executor: newAssembledBatchExecutor(),
})
if err != nil || first.Failed != 0 {
t.Fatalf("first result/error = %#v/%v", first, err)
}
secondBundle := assembledBatchBundle(t, "2026-05-31")
setWorkflowTemperatures(&secondBundle, 85)
second, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:30:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}}, Executor: newAssembledBatchExecutor(),
})
if err != nil || second.Failed != 0 || len(second.Reports) != len(first.Reports) {
t.Fatalf("second result/error = %#v/%v", second, err)
}
for _, item := range second.Reports {
pkg := loadBatchDataPackage(t, item.DataPackagePath)
if len(pkg.RecentChanges.Items) == 0 {
t.Fatalf("report %s data package has no changes from prior snapshot", item.ReportID)
}
}
}
func assembledBatchConfig(t *testing.T, notify bool) config.Config {
t.Helper()
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = notify
cfg.Notify.Distributor.Batch.Enabled = notify
return cfg
}
func assembledBatchBundle(t *testing.T, dates ...string) weatherdata.Bundle {
t.Helper()
bundle := workflowBundle(t)
location := mustLoadTestLocation(t, "America/Chicago")
for _, date := range dates {
periods := fullDayPeriods(t, date, location)
for index := range periods {
temperature := float64(60 + index)
periods[index].TemperatureF = &temperature
periods[index].TextDescription = "Partly cloudy"
}
bundle.Hourly.Periods = append(bundle.Hourly.Periods, periods...)
}
return bundle
}
func generatedTextForPrompt(promptID string) string {
switch promptID {
case "weather.today_generated_text":
return validTodayWorkflowJSON()
case "weather.tomorrow_generated_text":
return validTomorrowWorkflowJSON()
case "weather.daily_generated_text":
return validDailyWorkflowJSON()
case "weather.hourly_generated_text":
return validHourlyWorkflowJSON()
default:
return ""
}
}
func assertBatchPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path %q: %v", path, err)
}
}
}
func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
t.Helper()
data, err := os.ReadFile(item.MetadataPath)
if err != nil {
t.Fatalf("read metadata %q: %v", item.MetadataPath, err)
}
var metadata state.Metadata
if err := json.Unmarshal(data, &metadata); err != nil {
t.Fatalf("decode metadata %q: %v", item.MetadataPath, err)
}
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath ||
item.NotificationPath != metadata.NotificationPath {
t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata)
}
}
func readBatchNotificationArtifact(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read batch notification artifact: %v", err)
}
var artifact state.BatchDistributorNotificationArtifact
if err := json.Unmarshal(data, &artifact); err != nil {
t.Fatalf("decode batch notification artifact: %v", err)
}
return artifact
}
func loadBatchDataPackage(t *testing.T, path string) promptinput.Package {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read data package: %v", err)
}
pkg, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
return pkg
}

View File

@@ -0,0 +1,552 @@
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
failPromptExecution = "prompt execution"
failMetadata = "metadata"
failGeneratedText = "generated text"
failRenderContext = "render context"
failRenderedReportPath = "rendered report path"
failDistributorNotification = "distributor notification"
)
type failingPersistenceStore struct {
state.Store
failOperation string
failExecutionCall int
failMetadataCall int
executionCalls int
metadataCalls int
renderedReportPath string
}
func (s *failingPersistenceStore) SavePromptExecution(ctx context.Context, resolved report.Resolved, artifact state.PromptExecutionArtifact) (string, error) {
s.executionCalls++
if s.failOperation == failPromptExecution && (s.failExecutionCall == 0 || s.executionCalls == s.failExecutionCall) {
return "", errors.New("injected prompt execution persistence failure")
}
return s.Store.SavePromptExecution(ctx, resolved, artifact)
}
func (s *failingPersistenceStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
if s.failOperation == failGeneratedText {
return "", errors.New("injected generated text persistence failure")
}
return s.Store.SaveGeneratedText(ctx, resolved, data)
}
func (s *failingPersistenceStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
if s.failOperation == failRenderContext {
return "", errors.New("injected render context persistence failure")
}
return s.Store.SaveRenderContext(ctx, resolved, value)
}
func (s *failingPersistenceStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
if s.failOperation == failRenderedReportPath {
return s.renderedReportPath, nil
}
return s.Store.PrepareRenderedReport(ctx, resolved)
}
func (s *failingPersistenceStore) SaveDistributorNotification(ctx context.Context, resolved report.Resolved, artifact state.DistributorNotificationArtifact) (string, error) {
if s.failOperation == failDistributorNotification {
return "", errors.New("injected notification persistence failure")
}
return s.Store.SaveDistributorNotification(ctx, resolved, artifact)
}
func (s *failingPersistenceStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
s.metadataCalls++
if s.failOperation == failMetadata && s.metadataCalls == s.failMetadataCall {
return "", errors.New("injected metadata persistence failure")
}
return s.Store.SaveMetadata(ctx, metadata)
}
type artifactPathExecutor struct {
beforePreparationErr error
afterPreparationErr error
validation promptexec.ValidationStatus
}
func (e artifactPathExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
return promptexec.PromptInspection{}, errors.New("unexpected inspection")
}
func (e artifactPathExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{}, errors.New("unexpected inspection")
}
func (e artifactPathExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if e.beforePreparationErr != nil {
return nil, e.beforePreparationErr
}
now := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
if err := callback(promptexec.Preparation{
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "test",
ModelName: "test-model", DataPackagePath: req.DataPackagePath, StartedAt: now, EndedAt: now,
}, nil); err != nil {
return nil, err
}
if e.afterPreparationErr != nil {
return nil, e.afterPreparationErr
}
validation := e.validation
if validation == "" {
validation = promptexec.ValidationPassed
}
return &promptexec.Execution{
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash",
StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath,
RawOutput: []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`),
Validation: promptexec.NewValidation(validation, "json_schema", "daily.generated_text.schema.json", nil),
}, nil
}
type successfulNotifier struct{}
func (successfulNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
return &NotificationResult{RunID: "notification-run", Status: "succeeded", UploadStatus: "accepted"}, nil
}
type failingNotifier struct{}
func (failingNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
return nil, errors.New("injected notification failure")
}
func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
tests := []struct {
name string
failOperation string
failMetadataCall int
outputCopy bool
notify bool
want reachedPromptArtifacts
}{
{name: "preparation then metadata", failOperation: failMetadata, failMetadataCall: 1, want: reachedPromptArtifacts{preparation: true}},
{name: "raw output then execution", failOperation: failPromptExecution, want: reachedPromptArtifacts{preparation: true, metadata: true, raw: true}},
{name: "execution then metadata", failOperation: failMetadata, failMetadataCall: 2, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true}},
{name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}},
{name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}},
{name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}},
{name: "output copy then metadata", failOperation: failMetadata, failMetadataCall: 5, outputCopy: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}},
{name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall}
req.Store = store
if test.outputCopy {
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
paths.output = req.OutputPath
}
if test.notify {
req.Config.Notify.Distributor.Enabled = true
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
req.Notifier = successfulNotifier{}
req.noNotify = false
}
result, err := generatePromptReport(context.Background(), req)
if err == nil || result == nil {
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
}
assertReachedPromptArtifacts(t, result, paths, test.want)
})
}
}
func TestGeneratePromptReportFailureReceiptsExposeReachedPaths(t *testing.T) {
tests := []struct {
name string
executor artifactPathExecutor
want reachedPromptArtifacts
wantExecutionStatus state.PromptExecutionStatus
wantExecutionPaths state.PromptExecutionPaths
wantRawExecution bool
}{
{
name: "preparation failure",
executor: artifactPathExecutor{beforePreparationErr: promptexec.NewError(promptexec.Generation, "prepare failed", nil)},
want: reachedPromptArtifacts{preparation: true, metadata: true},
},
{
name: "operational execution failure",
executor: artifactPathExecutor{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider failed", nil)},
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true},
wantExecutionStatus: state.PromptExecutionFailed,
},
{
name: "completed validation rejection",
executor: artifactPathExecutor{validation: promptexec.ValidationFailed},
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
wantExecutionStatus: state.PromptExecutionValidationRejected,
wantRawExecution: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req, paths := promptArtifactRequest(t, test.executor)
if test.wantRawExecution {
test.wantExecutionPaths.RawOutputPath = paths.GeneratedTextRaw
}
result, err := generatePromptReport(context.Background(), req)
if err == nil || result == nil {
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
}
assertReachedPromptArtifacts(t, result, paths, test.want)
if test.wantExecutionStatus != "" {
artifact, loadErr := req.Store.LoadPromptExecution(context.Background(), result.ExecutionPath)
if loadErr != nil {
t.Fatalf("LoadPromptExecution() error = %v", loadErr)
}
if artifact.Status != test.wantExecutionStatus || artifact.Paths != test.wantExecutionPaths {
t.Fatalf("execution outcome/paths = %q/%#v, want %q/%#v", artifact.Status, artifact.Paths, test.wantExecutionStatus, test.wantExecutionPaths)
}
}
})
}
}
func TestCompletedExecutionArtifactTracksDownstreamLifecycle(t *testing.T) {
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
paths.output = req.OutputPath
req.Config.Notify.Distributor.Enabled = true
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
req.Notifier = successfulNotifier{}
req.noNotify = false
result, err := generatePromptReport(context.Background(), req)
if err != nil {
t.Fatalf("generatePromptReport() error = %v", err)
}
want := state.PromptExecutionPaths{
RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText,
RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport,
OutputPath: paths.output, NotificationPath: paths.Notification,
}
assertPersistedExecutionPaths(t, req.Store, result.ExecutionPath, want)
data, err := os.ReadFile(result.ExecutionPath)
if err != nil {
t.Fatalf("read execution artifact: %v", err)
}
text := string(data)
for _, forbidden := range []string{
"Showers are possible during the selected day", `"rawOutput":`, `"debug":`,
`"renderedMessages":`, `"structuredSchema":`, `"endpoint":`, `"parametersJSON":`,
"credential", "secret-value",
} {
if strings.Contains(text, forbidden) {
t.Fatalf("execution artifact contains unsafe generated or provider detail %q:\n%s", forbidden, text)
}
}
}
func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) {
tests := []struct {
name string
failOperation string
failExecutionCall int
failMetadataCall int
requestOutput bool
failOutputCopy bool
notify bool
notificationFailure bool
wantExecution reachedExecutionArtifacts
wantResult reachedPromptArtifacts
}{
{
name: "normalized text write", failOperation: failGeneratedText,
wantExecution: reachedExecutionArtifacts{raw: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
},
{
name: "normalized text checkpoint", failOperation: failPromptExecution, failExecutionCall: 2,
wantExecution: reachedExecutionArtifacts{raw: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
},
{
name: "normalized text metadata", failOperation: failMetadata, failMetadataCall: 3,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
},
{
name: "render context write", failOperation: failRenderContext,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
},
{
name: "render context checkpoint", failOperation: failPromptExecution, failExecutionCall: 3,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
},
{
name: "render context metadata", failOperation: failMetadata, failMetadataCall: 4,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
},
{
name: "managed report write", failOperation: failRenderedReportPath,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
},
{
name: "managed report checkpoint", failOperation: failPromptExecution, failExecutionCall: 4,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
},
{
name: "output copy write", requestOutput: true, failOutputCopy: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
},
{
name: "output copy checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
},
{
name: "output copy metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
},
{
name: "notification artifact write", failOperation: failDistributorNotification, requestOutput: true, notify: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
},
{
name: "notification checkpoint", failOperation: failPromptExecution, failExecutionCall: 6, requestOutput: true, notify: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
},
{
name: "notification metadata", failOperation: failMetadata, failMetadataCall: 6, requestOutput: true, notify: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
},
{
name: "notification operation", requestOutput: true, notify: true, notificationFailure: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
store := &failingPersistenceStore{
Store: req.Store, failOperation: test.failOperation,
failExecutionCall: test.failExecutionCall, failMetadataCall: test.failMetadataCall,
}
if test.failOperation == failRenderedReportPath {
store.renderedReportPath = t.TempDir()
}
req.Store = store
if test.requestOutput {
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
paths.output = req.OutputPath
}
if test.failOutputCopy {
blocker := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
t.Fatalf("write output blocker: %v", err)
}
req.OutputPath = filepath.Join(blocker, "daily.md")
paths.output = req.OutputPath
}
if test.notify {
req.Config.Notify.Distributor.Enabled = true
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
req.Notifier = successfulNotifier{}
req.noNotify = false
}
if test.notificationFailure {
req.Notifier = failingNotifier{}
}
result, err := generatePromptReport(context.Background(), req)
if err == nil || result == nil {
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
}
assertReachedPromptArtifacts(t, result, paths, test.wantResult)
assertPersistedExecutionPaths(t, store, result.ExecutionPath, executionPathsFor(paths, test.wantExecution))
})
}
}
type reachedExecutionArtifacts struct {
raw bool
normalized bool
renderContext bool
report bool
output bool
notification bool
}
func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths {
result := state.PromptExecutionPaths{}
if reached.raw {
result.RawOutputPath = paths.GeneratedTextRaw
}
if reached.normalized {
result.GeneratedTextPath = paths.GeneratedText
}
if reached.renderContext {
result.RenderContextPath = paths.RenderContext
}
if reached.report {
result.RenderedReportPath = paths.RenderedReport
}
if reached.output {
result.OutputPath = paths.output
}
if reached.notification {
result.NotificationPath = paths.Notification
}
return result
}
func assertPersistedExecutionPaths(t *testing.T, store state.Store, path string, want state.PromptExecutionPaths) {
t.Helper()
artifact, err := store.LoadPromptExecution(context.Background(), path)
if err != nil {
t.Fatalf("LoadPromptExecution() error = %v", err)
}
if artifact.Status != state.PromptExecutionSucceeded || artifact.Validation == nil || artifact.Validation.Status != promptexec.ValidationPassed {
t.Fatalf("execution outcome changed after downstream write: %#v", artifact)
}
if artifact.Provenance == nil || artifact.Provenance.RunID != "provider-run" || artifact.Provenance.PromptHash != "prompt-hash" {
t.Fatalf("execution provenance changed after downstream write: %#v", artifact.Provenance)
}
if artifact.Paths != want {
t.Fatalf("execution paths = %#v, want %#v", artifact.Paths, want)
}
}
type promptArtifactPaths struct {
state.ArtifactPaths
output string
}
type reachedPromptArtifacts struct {
preparation bool
execution bool
metadata bool
raw bool
normalized bool
renderContext bool
report bool
output bool
notification bool
}
func promptArtifactRequest(t *testing.T, executor promptexec.Executor) (promptReportRequest, promptArtifactPaths) {
t.Helper()
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
bundleData, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read daily fixture: %v", err)
}
var bundle weatherdata.Bundle
if err := json.Unmarshal(bundleData, &bundle); err != nil {
t.Fatalf("decode daily fixture: %v", err)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
paths, err := filesystemStore.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
debugWriter, err := state.NewPromptDebugWriter("")
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
return promptReportRequest{
GenerateRequest: GenerateRequest{Config: cfg, Report: ReportDaily, Executor: executor, Store: filesystemStore},
Resolved: resolved, Collection: collect.Result{Bundle: &bundle},
Inspection: PromptInspectionResult{
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
PromptHash: "prompt-hash", ProfileID: "test-profile", BackendID: "test", ModelName: "test-model",
},
DebugWriter: debugWriter, noNotify: true,
}, promptArtifactPaths{ArtifactPaths: paths}
}
func assertReachedPromptArtifacts(t *testing.T, result *ReportResult, paths promptArtifactPaths, want reachedPromptArtifacts) {
t.Helper()
if result.ModuleSnapshotPath != paths.ModuleSnapshot || result.DataPackagePath != paths.DataPackage {
t.Fatalf("base paths = module %q data %q, want %q and %q", result.ModuleSnapshotPath, result.DataPackagePath, paths.ModuleSnapshot, paths.DataPackage)
}
if result.Metadata.ModuleSnapshotPath != paths.ModuleSnapshot || result.Metadata.DataPackagePath != paths.DataPackage || result.Metadata.MetadataPath != paths.Metadata {
t.Fatalf("metadata base paths = %#v, want reached module/data paths and metadata destination", result.Metadata)
}
checks := []struct {
name string
got string
metadataGot string
inMetadata bool
path string
want bool
}{
{"preparation", result.PreparationPath, result.Metadata.PreparationPath, true, paths.Preparation, want.preparation},
{"execution", result.ExecutionPath, result.Metadata.ExecutionPath, true, paths.Execution, want.execution},
{"metadata", result.MetadataPath, "", false, paths.Metadata, want.metadata},
{"raw", result.GeneratedTextRawPath, result.Metadata.GeneratedTextRawPath, true, paths.GeneratedTextRaw, want.raw},
{"normalized", result.GeneratedTextPath, result.Metadata.GeneratedTextPath, true, paths.GeneratedText, want.normalized},
{"render context", result.RenderContextPath, result.Metadata.RenderContextPath, true, paths.RenderContext, want.renderContext},
{"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report},
{"output", result.OutputPath, "", false, paths.output, want.output},
{"notification", result.NotificationPath, result.Metadata.NotificationPath, true, paths.Notification, want.notification},
}
for _, check := range checks {
if check.want && check.got != check.path {
t.Errorf("%s path = %q, want reached path %q", check.name, check.got, check.path)
}
if check.want && check.inMetadata && check.metadataGot != check.path {
t.Errorf("metadata %s path = %q, want reached path %q", check.name, check.metadataGot, check.path)
}
if !check.want && check.got != "" {
t.Errorf("%s path = %q, want empty because artifact was not reached", check.name, check.got)
}
if !check.want && check.inMetadata && check.metadataGot != "" {
t.Errorf("metadata %s path = %q, want empty because artifact was not reached", check.name, check.metadataGot)
}
}
}

View File

@@ -0,0 +1,423 @@
package app
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
DebugWriter *state.PromptDebugWriter
noNotify bool
}
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
workflow, err := newPromptReportWorkflow(ctx, req)
if err != nil {
return nil, err
}
if err := workflow.buildInputs(); err != nil {
return workflow.result, err
}
execution, executeErr := workflow.executePrompt()
if executeErr != nil {
return workflow.result, workflow.handleExecutionFailure(executeErr)
}
if execution == nil {
err := promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
return workflow.result, saveErr
}
return workflow.result, workflow.reportError("execute prompt", err)
}
if err := workflow.persistExecutionDebug(*execution); err != nil {
return workflow.result, err
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
err := promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
return workflow.result, saveErr
}
return workflow.result, workflow.reportError("validate prompt execution", err)
}
if err := workflow.persistCompletedExecution(*execution); err != nil {
return workflow.result, err
}
if execution.Validation.Status == promptexec.ValidationFailed {
err := promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)
return workflow.result, workflow.reportError("validate prompt execution", err)
}
rendered, err := workflow.persistGeneratedContent(execution.RawOutput)
if err != nil {
return workflow.result, err
}
return workflow.finalizeReport(rendered)
}
type promptReportWorkflow struct {
ctx context.Context
req promptReportRequest
store state.Store
result *ReportResult
metadata state.Metadata
briefingMetadata briefing.Metadata
reportFacts ReportFacts
moduleSnapshot module.Snapshot
dataPackageBytes []byte
handler generatedtext.Handler
executionArtifact state.PromptExecutionArtifact
debugRef state.PromptDebugRef
prepared bool
callbackFailed bool
}
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, error) {
if req.Collection.Bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store
var err error
if store == nil {
store, err = defaultStore(req.Config)
if err != nil {
return nil, err
}
}
return &promptReportWorkflow{ctx: ctx, req: req, store: store}, nil
}
func (w *promptReportWorkflow) buildInputs() error {
paths, err := w.store.Paths(w.req.Resolved)
if err != nil {
return err
}
w.result = &ReportResult{}
priorSnapshot, err := w.store.FindPriorSnapshot(w.ctx, w.req.Resolved)
if err != nil {
return err
}
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
if err != nil {
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build report facts", err)
}
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
if err != nil {
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build module snapshot", err)
}
moduleSnapshotPath, err := w.store.SaveModuleSnapshot(w.ctx, w.req.Resolved, w.moduleSnapshot)
if err != nil {
return err
}
w.result.ModuleSnapshot = w.moduleSnapshot
w.result.ModuleSnapshotPath = moduleSnapshotPath
w.result.PriorSnapshot = priorSnapshot
recent, err := recentChanges(w.ctx, w.store, priorSnapshot, w.req.Resolved.Definition.ID, w.moduleSnapshot, w.req.Config.RecentChange)
if err != nil {
return err
}
w.result.RecentChanges = recent
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
w.metadata = state.BuildPromptMetadataFromBriefingMetadata(w.req.Resolved, w.briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
})
w.result.Metadata = w.metadata
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(w.metadata), Modules: w.moduleSnapshot, RecentChanges: recent,
})
if err != nil {
return w.reportError("build data package", err)
}
w.dataPackageBytes, err = promptinput.MarshalYAML(dataPackage)
if err != nil {
return err
}
dataPackagePath, err := w.store.SaveDataPackageBytes(w.ctx, w.req.Resolved, w.dataPackageBytes)
if err != nil {
return err
}
w.metadata.DataPackagePath = dataPackagePath
w.result.DataPackage = dataPackage
w.result.DataPackagePath = dataPackagePath
w.result.Metadata = w.metadata
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
if err != nil {
return w.reportError("lookup generated text catalog", err)
}
w.debugRef = state.PromptDebugRef{
ReportID: w.req.Resolved.Definition.ID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.metadata.RunID,
}
return nil
}
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackageBytes,
DataPackagePath: w.result.DataPackagePath, CaptureDebug: w.req.DebugWriter.Enabled(),
}, w.persistPreparation)
}
func (w *promptReportWorkflow) persistPreparation(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationSucceeded,
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
DataPackagePath: w.result.DataPackagePath, Preparation: &preparation,
StartedAt: preparation.StartedAt, EndedAt: preparation.EndedAt, Duration: preparation.Duration,
}
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
if err != nil {
w.callbackFailed = true
return err
}
w.prepared = true
w.result.PreparationPath = path
w.metadata.PreparationPath = path
w.result.Metadata = w.metadata
debugPath, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
if err != nil {
w.callbackFailed = true
return promptDebugWriteError(err)
}
if debugPath != "" {
w.result.LLMDebugPath = debugPath
}
if err := w.saveMetadata(); err != nil {
w.callbackFailed = true
return err
}
return nil
}
func (w *promptReportWorkflow) handleExecutionFailure(executeErr error) error {
if w.callbackFailed {
return executeErr
}
executeErr = classifiedPromptError("prompt execution failed", executeErr)
if !w.prepared {
if err := w.persistPreparationFailure(executeErr); err != nil {
return err
}
return w.reportError("prepare prompt", executeErr)
}
if promptexec.CategoryOf(executeErr) != "" {
if err := w.persistOperationalExecutionFailure(executeErr); err != nil {
return err
}
}
return w.reportError("execute prompt", executeErr)
}
func (w *promptReportWorkflow) persistPreparationFailure(executeErr error) error {
startedAt, endedAt := time.Now(), time.Now()
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationFailed,
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
DataPackagePath: w.result.DataPackagePath, StartedAt: startedAt, EndedAt: endedAt,
Error: state.NewPromptArtifactError(executeErr),
}
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
if err != nil {
return err
}
w.result.PreparationPath = path
w.metadata.PreparationPath = path
w.result.Metadata = w.metadata
return w.saveMetadata()
}
func (w *promptReportWorkflow) persistOperationalExecutionFailure(executeErr error) error {
artifact := failedPromptExecutionArtifact(w.req.Resolved, w.metadata, w.req.Inspection, executeErr)
path, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, artifact)
if err != nil {
return err
}
w.result.ExecutionPath = path
w.metadata.ExecutionPath = path
w.result.Metadata = w.metadata
return w.saveMetadata()
}
func (w *promptReportWorkflow) persistExecutionDebug(execution promptexec.Execution) error {
debugPath, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
if err != nil {
return w.reportError("write prompt debug", promptDebugWriteError(err))
}
if debugPath != "" {
w.result.LLMDebugPath = debugPath
}
return nil
}
func (w *promptReportWorkflow) persistCompletedExecution(execution promptexec.Execution) error {
rawPath, err := w.store.SaveGeneratedTextRaw(w.ctx, w.req.Resolved, execution.RawOutput)
if err != nil {
return err
}
w.result.GeneratedTextRawPath = rawPath
w.metadata.GeneratedTextRawPath = rawPath
w.result.Metadata = w.metadata
w.executionArtifact = state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion,
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
Provenance: ptr(state.PromptExecutionProvenanceFrom(execution)), Validation: &execution.Validation,
Paths: state.PromptExecutionPaths{RawOutputPath: rawPath},
StartedAt: execution.StartedAt, EndedAt: execution.EndedAt, Duration: execution.Duration,
}
if execution.Validation.Status == promptexec.ValidationPassed {
w.executionArtifact.Status = state.PromptExecutionSucceeded
} else {
w.executionArtifact.Status = state.PromptExecutionValidationRejected
}
executionPath, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, w.executionArtifact)
if err != nil {
return err
}
w.result.ExecutionPath = executionPath
w.metadata.ExecutionPath = executionPath
w.result.Metadata = w.metadata
return w.saveMetadata()
}
func (w *promptReportWorkflow) persistGeneratedContent(raw []byte) ([]byte, error) {
generatedText, normalized, err := w.handler.Validate(raw)
if err != nil {
return nil, w.reportError("validate generated text", err)
}
generatedTextPath, err := w.store.SaveGeneratedText(w.ctx, w.req.Resolved, normalized)
if err != nil {
return nil, err
}
w.result.GeneratedTextPath = generatedTextPath
w.metadata.GeneratedTextPath = generatedTextPath
w.result.Metadata = w.metadata
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.GeneratedTextPath = generatedTextPath }); err != nil {
return nil, err
}
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
if err != nil {
return nil, w.reportError("build render context", err)
}
renderContextPath, err := w.store.SaveRenderContext(w.ctx, w.req.Resolved, renderContext)
if err != nil {
return nil, err
}
w.result.RenderContextPath = renderContextPath
w.metadata.RenderContextPath = renderContextPath
w.result.Metadata = w.metadata
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.RenderContextPath = renderContextPath }); err != nil {
return nil, err
}
rendered, err := w.handler.Render(renderContext)
if err != nil {
return nil, w.reportError("render template", err)
}
return rendered, nil
}
func (w *promptReportWorkflow) finalizeReport(rendered []byte) (*ReportResult, error) {
reportPath, err := w.store.PrepareRenderedReport(w.ctx, w.req.Resolved)
if err != nil {
return w.result, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return w.result, err
}
w.result.ReportPath = reportPath
w.metadata.RenderedReportPath = reportPath
w.result.Metadata = w.metadata
if err := w.persistReachedPath(func(paths *state.PromptExecutionPaths) { paths.RenderedReportPath = reportPath }); err != nil {
return w.result, err
}
finalized, err := finalizeRenderedReport(w.ctx, finalizeRenderedReportRequest{
Config: w.req.Config, Store: w.store, Resolved: w.req.Resolved, Metadata: w.metadata, MetadataPath: w.result.MetadataPath,
ExecutionArtifact: &w.executionArtifact, ManagedReportPath: reportPath, OutputPath: w.req.OutputPath,
Notifier: w.req.Notifier, noNotify: w.req.noNotify,
})
w.result.OutputPath, w.result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
w.result.Metadata, w.result.MetadataPath, w.result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
return w.result, err
}
func (w *promptReportWorkflow) persistReachedPath(update func(*state.PromptExecutionPaths)) error {
return persistReachedPromptPath(w.ctx, w.store, w.req.Resolved, &w.executionArtifact, update)
}
func (w *promptReportWorkflow) persistReachedPathAndMetadata(update func(*state.PromptExecutionPaths)) error {
if err := w.persistReachedPath(update); err != nil {
return err
}
return w.saveMetadata()
}
func (w *promptReportWorkflow) saveMetadata() error {
path, err := w.store.SaveMetadata(w.ctx, w.metadata)
if err != nil {
return err
}
w.result.Metadata = w.metadata
w.result.MetadataPath = path
return nil
}
func (w *promptReportWorkflow) reportError(operation string, err error) error {
return generatedReportError(w.req.Resolved, w.metadata.RunID, operation, err)
}
func persistReachedPromptPath(
ctx context.Context,
store state.Store,
resolved report.Resolved,
artifact *state.PromptExecutionArtifact,
update func(*state.PromptExecutionPaths),
) error {
update(&artifact.Paths)
_, err := store.SavePromptExecution(ctx, resolved, *artifact)
return err
}
func failedPromptExecutionArtifact(resolved report.Resolved, metadata state.Metadata, inspection PromptInspectionResult, err error) state.PromptExecutionArtifact {
now := time.Now()
return state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion, Status: state.PromptExecutionFailed,
ReportID: resolved.Definition.ID, RunID: metadata.RunID, PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion, StartedAt: now, EndedAt: now,
Error: state.NewPromptArtifactError(err),
}
}
func classifiedPromptError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.Generation, operation, err)
}
func promptDebugWriteError(err error) error {
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
}
func ptr[T any](value T) *T { return &value }

View File

@@ -0,0 +1,142 @@
package app
import (
"context"
"os"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
// PromptInspectionRequest contains the non-executing inputs required to
// validate one report's configured prompt and profile.
type PromptInspectionRequest struct {
Resolved report.Resolved
Executor promptexec.Executor
Promptkit config.PromptkitConfig
LookupEnv func(string) (string, bool)
}
// PromptInspectionResult contains only safe identity and provenance from a
// prompt/profile inspection.
type PromptInspectionResult struct {
PromptID string
PromptVersion string
PromptHash string
ProfileID string
BackendID string
ModelName string
}
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
// needed by a batch before collection begins.
type PromptExecutionsInspectionRequest struct {
Resolved []report.Resolved
Executor promptexec.Executor
Promptkit config.PromptkitConfig
LookupEnv func(string) (string, bool)
}
// InspectPromptExecution validates the exact prompt and profile needed for a
// report before collection, execution, or durable writes begin.
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
Resolved: []report.Resolved{req.Resolved},
Executor: req.Executor,
Promptkit: req.Promptkit,
LookupEnv: req.LookupEnv,
})
if err != nil {
return PromptInspectionResult{}, err
}
return results[req.Resolved.Definition.ID], nil
}
// InspectPromptExecutions validates exact prompt contracts and their unique
// effective profiles. It performs no collection, execution, or durable write.
func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) {
if req.Executor == nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
}
results := make(map[report.ID]PromptInspectionResult, len(req.Resolved))
profiles := map[string]promptexec.ProfileInspection{}
for _, resolved := range req.Resolved {
definition := resolved.Definition
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
}
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
if err != nil {
return nil, promptInspectionError("prompt inspection failed", err)
}
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
}
if !validPromptInput(inspection.Inputs) {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
}
if !validPromptOutput(definition, inspection.Output) {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
}
profileID := req.Promptkit.Profile
if profileID == "" {
profileID = inspection.DefaultProfileID
}
if strings.TrimSpace(profileID) == "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
}
profile, ok := profiles[profileID]
if !ok {
profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
if err != nil {
return nil, err
}
profiles[profileID] = profile
}
results[definition.ID] = PromptInspectionResult{
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
}
}
return results, nil
}
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
profile, err := executor.InspectProfile(ctx, profileID)
if err != nil {
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
}
if profile.ProfileID != profileID {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
}
if profile.CredentialRequired {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
}
if strings.TrimSpace(profile.APIKeyEnv) != "" {
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
value, present := lookupEnv(profile.APIKeyEnv)
if !present || strings.TrimSpace(value) == "" {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
}
}
return profile, nil
}
func validPromptInput(inputs []promptexec.InputDefinition) bool {
return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml"
}
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json"
}
func promptInspectionError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
}

View File

@@ -0,0 +1,191 @@
package app
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{
prompt: validPromptInspection(resolved.Definition),
profiles: map[string]promptexec.ProfileInspection{
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
},
}
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
if err != nil {
t.Fatalf("InspectPromptExecution(default) error = %v", err)
}
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
t.Fatalf("default result = %#v", defaultResult)
}
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
})
if err != nil {
t.Fatalf("InspectPromptExecution(override) error = %v", err)
}
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
t.Fatalf("override result = %#v", overrideResult)
}
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
}
}
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
resolved := inspectionResolved(t)
basePrompt := validPromptInspection(resolved.Definition)
tests := []struct {
name string
prompt promptexec.PromptInspection
profile promptexec.ProfileInspection
lookupEnv func(string) (string, bool)
wantCategory promptexec.ErrorCategory
}{
{
name: "extra input",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "wrong schema",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.Output.SchemaPath = "unexpected.schema.json"
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "direct key",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
wantCategory: promptexec.MissingCredential,
},
{
name: "missing environment credential",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
lookupEnv: func(string) (string, bool) { return "", false },
wantCategory: promptexec.MissingCredential,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
}
})
}
}
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
}
if strings.Contains(err.Error(), "resolved-secret-value") {
t.Fatalf("inspection error leaks provider value: %v", err)
}
}
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
first := inspectionResolved(t)
second := first
second.Definition.ID = report.Today
second.Definition.PromptID = "weather.today"
executor := &inspectionExecutor{
prompt: validPromptInspection(first.Definition),
profiles: map[string]promptexec.ProfileInspection{
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
},
}
executor.prompts = map[string]promptexec.PromptInspection{
first.Definition.PromptID: validPromptInspection(first.Definition),
second.Definition.PromptID: validPromptInspection(second.Definition),
}
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
if err != nil {
t.Fatalf("InspectPromptExecutions() error = %v", err)
}
if len(results) != 2 || len(executor.profileRequests) != 1 {
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
}
}
type inspectionPromptRequest struct {
id string
version string
}
type inspectionExecutor struct {
prompt promptexec.PromptInspection
prompts map[string]promptexec.PromptInspection
profiles map[string]promptexec.ProfileInspection
promptErr error
promptRequests []inspectionPromptRequest
profileRequests []string
}
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
if e.promptErr != nil {
return promptexec.PromptInspection{}, e.promptErr
}
if prompt, ok := e.prompts[id]; ok {
return prompt, nil
}
return e.prompt, nil
}
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
e.profileRequests = append(e.profileRequests, id)
value, ok := e.profiles[id]
if !ok {
return promptexec.ProfileInspection{}, errors.New("profile missing")
}
return value, nil
}
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
return nil, errors.New("unexpected execution")
}
func inspectionResolved(t *testing.T) report.Resolved {
t.Helper()
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
Location: time.UTC,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
return promptexec.PromptInspection{
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
}
}

View File

@@ -0,0 +1,659 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type workflowCollector struct {
result *collect.Result
err error
calls int
}
func (c *workflowCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
c.calls++
if c.err != nil {
return nil, c.err
}
return c.result, nil
}
type workflowExecutor struct {
definition report.Definition
raw []byte
inspectionErr error
profile promptexec.ProfileInspection
beforePreparationErr error
afterCallbackErr error
afterPreparationErr error
validation promptexec.ValidationStatus
executeCalls int
providerCalls int
request promptexec.ExecuteRequest
beforeProvider func()
preparationDebug *promptexec.PreparationDebug
executionDebug *promptexec.ExecutionDebug
}
func (e *workflowExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
if e.inspectionErr != nil {
return promptexec.PromptInspection{}, e.inspectionErr
}
if id != e.definition.PromptID || version != e.definition.PromptVersion {
return promptexec.PromptInspection{}, errors.New("unexpected prompt identity")
}
return validPromptInspection(e.definition), nil
}
func (e *workflowExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
profile := e.profile
if profile.ProfileID == "" {
profile = promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}
}
return profile, nil
}
func (e *workflowExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
e.executeCalls++
e.request = req
if e.beforePreparationErr != nil {
return nil, e.beforePreparationErr
}
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
preparation := promptexec.Preparation{
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
}
if err := callback(preparation, e.preparationDebug); err != nil {
return nil, err
}
if e.afterCallbackErr != nil {
return nil, e.afterCallbackErr
}
if e.beforeProvider != nil {
e.beforeProvider()
}
e.providerCalls++
if e.afterPreparationErr != nil {
return nil, e.afterPreparationErr
}
validation := e.validation
if validation == "" {
validation = promptexec.ValidationPassed
}
return &promptexec.Execution{
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath, RawOutput: e.raw,
Debug: e.executionDebug,
Validation: promptexec.NewValidation(validation, "json_schema", e.definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
}, nil
}
type workflowNotifier struct {
requests []NotificationRequest
err error
}
func (n *workflowNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
n.requests = append(n.requests, req)
if n.err != nil {
return nil, n.err
}
return &NotificationResult{
RunID: "notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
}, nil
}
func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
tests := []struct {
name string
kind ReportKind
id report.ID
date time.Time
raw string
wantOutput string
}{
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantOutput: "Showers are possible during the selected day."},
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantOutput: "Today starts with showers before improving."},
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantOutput: "Tomorrow starts with showers before improving."},
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON(), wantOutput: "Storm chances increase through late morning."},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
definition := report.DefaultRegistry().MustLookup(test.id)
bundle := workflowBundle(t)
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
notifier := &workflowNotifier{}
outputPath := filepath.Join(t.TempDir(), test.name+".md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if result.Metadata.ReportID != test.id || result.Metadata.PromptID != definition.PromptID {
t.Fatalf("metadata identity = %q/%q, want %q/%q", result.Metadata.ReportID, result.Metadata.PromptID, test.id, definition.PromptID)
}
if executor.request.PromptVersion != definition.PromptVersion {
t.Fatalf("prompt version = %q, want %q", executor.request.PromptVersion, definition.PromptVersion)
}
filesystem, storeErr := state.NewFilesystemStore(cfg.Workspace)
if storeErr != nil {
t.Fatalf("NewFilesystemStore() error = %v", storeErr)
}
preparation, loadErr := filesystem.LoadPromptPreparation(context.Background(), result.PreparationPath)
if loadErr != nil || preparation.PromptVersion != definition.PromptVersion {
t.Fatalf("persisted preparation prompt version = %q, error %v, want %q", preparation.PromptVersion, loadErr, definition.PromptVersion)
}
if collector.calls != 1 || executor.executeCalls != 1 || executor.providerCalls != 1 {
t.Fatalf("calls = collect %d execute %d provider %d, want one each", collector.calls, executor.executeCalls, executor.providerCalls)
}
persisted, readErr := os.ReadFile(result.DataPackagePath)
if readErr != nil {
t.Fatalf("read data package: %v", readErr)
}
if !bytes.Equal(executor.request.DataPackage, persisted) {
t.Fatal("executor data package differs from exact persisted YAML bytes")
}
managed, readErr := os.ReadFile(result.ReportPath)
if readErr != nil || !strings.Contains(string(managed), test.wantOutput) {
t.Fatalf("managed report = %q, error %v, want generated template output %q", managed, readErr, test.wantOutput)
}
copied, readErr := os.ReadFile(outputPath)
if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath {
t.Fatalf("output copy mismatch/error/path = %v/%q", readErr, result.OutputPath)
}
if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath || notifier.requests[0].ReportPath == outputPath {
t.Fatalf("notification requests = %#v, want managed report source", notifier.requests)
}
wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup
if notifier.requests[0].PipelineID != wantPipeline {
t.Fatalf("pipeline = %q, want %q", notifier.requests[0].PipelineID, wantPipeline)
}
validDate := result.Metadata.ValidPeriod.Start.Format("2006-01-02")
wantBundlePaths := workflowBundlePaths(test.id, validDate, result.Metadata.RunID)
if strings.Join(notifier.requests[0].BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths)
}
managedName := filepath.Base(result.ReportPath)
if !strings.HasPrefix(managedName, "report.") || !strings.Contains(managedName, "_"+test.name) || !strings.HasSuffix(managedName, ".md") || filepath.Base(result.OutputPath) != test.name+".md" {
t.Fatalf("output names = managed %q copy %q", result.ReportPath, result.OutputPath)
}
})
}
}
type preparationFailingStore struct {
state.Store
}
type renderContextFailingStore struct {
state.Store
}
func (s renderContextFailingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
path, err := s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
if err != nil {
return "", err
}
for index := range snapshot.Outputs {
snapshot.Outputs[index].Value = "invalid module value"
}
return path, nil
}
func (s preparationFailingStore) SavePromptPreparation(context.Context, report.Resolved, state.PromptPreparationArtifact) (string, error) {
return "", errors.New("injected preparation persistence failure")
}
func TestGenerateDetailedStopsAtConsequentialPromptFailures(t *testing.T) {
tests := []struct {
name string
configure func(*workflowExecutor)
wantCategory promptexec.ErrorCategory
wantPreparation bool
wantExecution bool
wantRaw bool
wantProviderCall int
}{
{name: "preparation", configure: func(e *workflowExecutor) {
e.beforePreparationErr = promptexec.NewError(promptexec.Generation, "preparation failed", nil)
}, wantCategory: promptexec.Generation, wantPreparation: true},
{name: "credential disappears", configure: func(e *workflowExecutor) {
e.afterCallbackErr = promptexec.NewError(promptexec.MissingCredential, "credential unavailable", nil)
}, wantCategory: promptexec.MissingCredential, wantPreparation: true, wantExecution: true},
{name: "capacity is not retried", configure: func(e *workflowExecutor) {
e.afterPreparationErr = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
}, wantCategory: promptexec.Capacity, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "canceled", configure: func(e *workflowExecutor) {
e.afterPreparationErr = promptexec.NewError(promptexec.Canceled, "request canceled", context.Canceled)
}, wantCategory: promptexec.Canceled, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "deadline", configure: func(e *workflowExecutor) {
e.afterPreparationErr = promptexec.NewError(promptexec.DeadlineExceeded, "deadline exceeded", context.DeadlineExceeded)
}, wantCategory: promptexec.DeadlineExceeded, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "generation", configure: func(e *workflowExecutor) {
e.afterPreparationErr = promptexec.NewError(promptexec.Generation, "generation failed", nil)
}, wantCategory: promptexec.Generation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "operational validation error", configure: func(e *workflowExecutor) {
e.afterPreparationErr = promptexec.NewError(promptexec.OperationalValidation, "validator failed", nil)
}, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "operational validation incomplete", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationSkipped }, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
{name: "schema rejection", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationFailed }, wantCategory: promptexec.ValidationRejected, wantPreparation: true, wantExecution: true, wantRaw: true, wantProviderCall: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = false
definition := report.DefaultRegistry().MustLookup(report.Daily)
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
test.configure(executor)
bundle := workflowBundle(t)
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
Collector: collector, Executor: executor,
})
if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory {
t.Fatalf("result/error/category = %#v/%v/%q, want partial result and %q", result, err, promptexec.CategoryOf(err), test.wantCategory)
}
if (result.PreparationPath != "") != test.wantPreparation || (result.ExecutionPath != "") != test.wantExecution || (result.GeneratedTextRawPath != "") != test.wantRaw {
t.Fatalf("paths = preparation %q execution %q raw %q", result.PreparationPath, result.ExecutionPath, result.GeneratedTextRawPath)
}
if executor.executeCalls != 1 || executor.providerCalls != test.wantProviderCall {
t.Fatalf("calls = execute %d provider %d, want 1/%d", executor.executeCalls, executor.providerCalls, test.wantProviderCall)
}
})
}
}
func TestGenerateDetailedRejectsInspectionAndCredentialsBeforeCollection(t *testing.T) {
tests := []struct {
name string
configure func(*workflowExecutor)
wantCategory promptexec.ErrorCategory
}{
{name: "inspection", configure: func(e *workflowExecutor) { e.inspectionErr = errors.New("inspection unavailable") }, wantCategory: promptexec.InvalidConfiguration},
{name: "credential", configure: func(e *workflowExecutor) {
e.profile = promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true}
}, wantCategory: promptexec.MissingCredential},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
definition := report.DefaultRegistry().MustLookup(report.Daily)
executor := &workflowExecutor{definition: definition}
test.configure(executor)
collector := &workflowCollector{err: errors.New("collector must not run")}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
Collector: collector, Executor: executor,
})
if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 {
t.Fatalf("result/error/category/collect/execute = %#v/%v/%q/%d/%d", result, err, promptexec.CategoryOf(err), collector.calls, executor.executeCalls)
}
entries, readErr := os.ReadDir(cfg.Workspace.Root)
if readErr != nil || len(entries) != 0 {
t.Fatalf("workspace entries/error = %#v/%v, want no writes before collection", entries, readErr)
}
})
}
}
func TestGenerateDetailedStopsProviderWhenPreparationCannotPersist(t *testing.T) {
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = false
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
definition := report.DefaultRegistry().MustLookup(report.Daily)
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
bundle := workflowBundle(t)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem},
})
if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 {
t.Fatalf("result/error/preparation/provider = %#v/%v/%q/%d", result, err, result.PreparationPath, executor.providerCalls)
}
}
func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T) {
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = false
now := workflowTime("2026-05-29T08:30:00-05:00")
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
resolved, err := ResolveGenerate(request, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
paths, err := filesystem.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
checked := false
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
executor.beforeProvider = func() {
checked = true
if _, statErr := os.Stat(paths.Preparation); statErr != nil {
t.Fatalf("preparation was not durable before provider execution: %v", statErr)
}
}
bundle := workflowBundle(t)
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
request.Executor = executor
request.Store = filesystem
result, err := GenerateDetailed(context.Background(), request)
if err != nil || result == nil || !checked || result.OutputPath != "" {
t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath)
}
}
func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *testing.T) {
tests := []struct {
name string
raw string
configure func(*GenerateRequest, *workflowNotifier)
wantRaw bool
wantNormalized bool
wantContext bool
wantReport bool
wantOutput bool
wantNotify bool
}{
{name: "generated text decode", raw: `{`, wantRaw: true},
{name: "generated text domain", raw: `{}`, wantRaw: true},
{name: "render context build", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
req.Store = renderContextFailingStore{Store: req.Store}
}, wantRaw: true, wantNormalized: true},
{name: "render context persistence", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderContext}
}, wantRaw: true, wantNormalized: true},
{name: "template write", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
blocker := filepath.Join(t.TempDir(), "report-blocker")
if err := os.Mkdir(blocker, 0o700); err != nil {
t.Fatalf("create report blocker: %v", err)
}
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker}
}, wantRaw: true, wantNormalized: true, wantContext: true},
{name: "output copy", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
req.OutputPath = t.TempDir()
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true},
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
notifier.err = errors.New("notification rejected")
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
definition := report.DefaultRegistry().MustLookup(report.Daily)
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
notifier := &workflowNotifier{}
bundle := workflowBundle(t)
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
req := GenerateRequest{
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
Executor: executor, Notifier: notifier, Store: filesystem,
}
if test.configure != nil {
test.configure(&req, notifier)
}
result, err := GenerateDetailed(context.Background(), req)
if err == nil || result == nil {
t.Fatalf("result/error = %#v/%v, want partial result and error", result, err)
}
if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized ||
(result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport ||
(result.OutputPath != "") != test.wantOutput || (result.NotificationPath != "") != test.wantNotify {
t.Fatalf("reached paths = raw %q normalized %q context %q report %q output %q notification %q", result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.OutputPath, result.NotificationPath)
}
if test.wantRaw {
persisted, readErr := os.ReadFile(result.GeneratedTextRawPath)
if readErr != nil || !bytes.Equal(persisted, []byte(test.raw)) {
t.Fatalf("retained raw output = %q, error %v", persisted, readErr)
}
}
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath) {
t.Fatalf("notification requests = %#v", notifier.requests)
}
})
}
}
func TestGenerateDetailedDebugFailuresRespectProviderBoundary(t *testing.T) {
tests := []struct {
name string
createCollision func(string, report.Resolved) error
wantProviderCalls int
wantPreparationFile bool
}{
{
name: "preparation debug",
createCollision: func(root string, resolved report.Resolved) error {
path := workflowDebugRunPath(root, resolved)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, []byte("not a directory"), 0o600)
},
},
{
name: "execution debug", wantProviderCalls: 1, wantPreparationFile: true,
createCollision: func(root string, resolved report.Resolved) error {
return os.MkdirAll(filepath.Join(workflowDebugRunPath(root, resolved), "execution.json"), 0o700)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = false
now := workflowTime("2026-05-29T08:30:00-05:00")
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
resolved, err := ResolveGenerate(request, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
debugRoot := filepath.Join(t.TempDir(), "prompt-debug")
if err := test.createCollision(debugRoot, resolved); err != nil {
t.Fatalf("create debug collision: %v", err)
}
bundle := workflowBundle(t)
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
request.Executor = executor
request.LLMDebugDir = debugRoot
result, err := GenerateDetailed(context.Background(), request)
if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("result/error/category = %#v/%v/%q", result, err, promptexec.CategoryOf(err))
}
if executor.providerCalls != test.wantProviderCalls || result.PreparationPath == "" || result.ExecutionPath != "" || result.GeneratedTextRawPath != "" {
t.Fatalf("provider/preparation/metadata/execution/raw = %d/%q/%q/%q/%q", executor.providerCalls, result.PreparationPath, result.MetadataPath, result.ExecutionPath, result.GeneratedTextRawPath)
}
if test.wantPreparationFile && result.MetadataPath == "" {
t.Fatal("execution debug failure lost previously persisted metadata")
}
preparationDebug := filepath.Join(workflowDebugRunPath(debugRoot, resolved), "preparation.json")
_, statErr := os.Stat(preparationDebug)
if (statErr == nil) != test.wantPreparationFile {
t.Fatalf("preparation debug stat error = %v, want file %t", statErr, test.wantPreparationFile)
}
})
}
}
func workflowDebugRunPath(root string, resolved report.Resolved) string {
return filepath.Join(root, string(resolved.Definition.ID), resolved.ValidPeriod.Start.Format("2006-01-02"), resolved.Metadata().RunID)
}
func workflowBundlePaths(id report.ID, validDate, runID string) []string {
switch id {
case report.Daily:
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md"}
case report.Today:
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "today/index.md"}
case report.Tomorrow:
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "tomorrow/index.md"}
case report.Hourly:
return []string{"hourly/index.md"}
default:
return nil
}
}
func TestGenerateDetailedSelectsPriorSnapshotsForRetainedReports(t *testing.T) {
tests := []struct {
name string
kind ReportKind
id report.ID
date time.Time
raw string
wantPrior bool
wantRecentChanges bool
}{
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON()},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := workflowConfig(t)
cfg.Notify.Distributor.Enabled = false
definition := report.DefaultRegistry().MustLookup(test.id)
firstBundle := workflowBundle(t)
setWorkflowTemperatures(&firstBundle, 45)
first, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:00:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}},
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
})
if err != nil {
t.Fatalf("first GenerateDetailed() error = %v", err)
}
secondBundle := workflowBundle(t)
setWorkflowTemperatures(&secondBundle, 85)
second, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}},
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
})
if err != nil {
t.Fatalf("second GenerateDetailed() error = %v", err)
}
if test.wantPrior && (second.PriorSnapshot == nil || second.PriorSnapshot.Metadata.RunID != first.Metadata.RunID || second.PriorSnapshot.Metadata.ReportID != test.id) {
t.Fatalf("prior snapshot = %#v, want first %s run %q", second.PriorSnapshot, test.id, first.Metadata.RunID)
}
if !test.wantPrior && second.PriorSnapshot != nil {
t.Fatalf("prior snapshot = %#v, want none for non-overlapping rolling window", second.PriorSnapshot)
}
if (len(second.RecentChanges) > 0) != test.wantRecentChanges {
t.Fatalf("recent changes = %#v, want present %t", second.RecentChanges, test.wantRecentChanges)
}
if (len(second.DataPackage.RecentChanges.Items) > 0) != test.wantRecentChanges {
t.Fatalf("data package recent changes = %#v, want present %t", second.DataPackage.RecentChanges.Items, test.wantRecentChanges)
}
})
}
}
func setWorkflowTemperatures(bundle *weatherdata.Bundle, temperature float64) {
for index := range bundle.Hourly.Periods {
value := temperature
bundle.Hourly.Periods[index].TemperatureF = &value
}
}
func workflowConfig(t *testing.T) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Location.ID = "home"
cfg.Location.Name = "Testville"
cfg.Location.Region = "MO"
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "reports.{report_id}.{artifact_group}"
return cfg
}
func workflowBundle(t *testing.T) weatherdata.Bundle {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read bundle fixture: %v", err)
}
var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err)
}
future := bundle.Hourly.Periods[0]
future.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
future.EndTime = workflowTime("2026-05-30T07:00:00-05:00")
bundle.Hourly.Periods = append(bundle.Hourly.Periods, future)
futureNarrative := bundle.Narrative.Periods[0]
futureNarrative.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
futureNarrative.EndTime = workflowTime("2026-05-30T18:00:00-05:00")
futureNarrative.Name = "Tomorrow"
bundle.Narrative.Periods = append(bundle.Narrative.Periods, futureNarrative)
return bundle
}
func workflowTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}
func validHourlyWorkflowJSON() string {
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
}
func validTomorrowWorkflowJSON() string {
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
}
func validTodayWorkflowJSON() string {
return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
}
func validDailyWorkflowJSON() string {
return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
}

View File

@@ -0,0 +1,21 @@
package app
import (
"testing"
"time"
)
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,72 @@
{
"categorical:TSTM": {
"plain_language": "General or non-severe thunderstorms.",
"official_description": "No severe thunderstorms expected.",
"relative_level": "0 of 5"
},
"categorical:MRGL": {
"plain_language": "Isolated severe storms possible.",
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
"relative_level": "1 of 5"
},
"categorical:SLGT": {
"plain_language": "Scattered severe storms possible.",
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
"relative_level": "2 of 5"
},
"categorical:ENH": {
"plain_language": "Numerous severe storms possible.",
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
"relative_level": "3 of 5"
},
"categorical:MDT": {
"plain_language": "Widespread severe storms likely.",
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
"relative_level": "4 of 5"
},
"categorical:HIGH": {
"plain_language": "Major severe outbreak expected.",
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
"relative_level": "5 of 5"
},
"tornado:CIG1": {
"plain_language": "Conditional potential for significant tornadoes.",
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"tornado:CIG2": {
"plain_language": "Conditional potential for strong tornadoes.",
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"tornado:CIG3": {
"plain_language": "Conditional potential for violent tornadoes.",
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"wind:CIG1": {
"plain_language": "Conditional potential for significant severe wind.",
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"wind:CIG2": {
"plain_language": "Conditional potential for intense severe wind.",
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"wind:CIG3": {
"plain_language": "Conditional potential for extreme severe wind.",
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"hail:CIG1": {
"plain_language": "Conditional potential for significant hail.",
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 2"
},
"hail:CIG2": {
"plain_language": "Conditional potential for giant hail.",
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 2"
}
}

View File

@@ -155,10 +155,10 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
@@ -227,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
@@ -253,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
@@ -264,6 +261,9 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
}
if strings.Contains(jsonText, `"alerts"`) {
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
}
}
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {

View File

@@ -603,7 +603,7 @@ func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})

View File

@@ -20,7 +20,6 @@ type MetadataModule struct {
ValidPeriod timeutil.Period `json:"valid_period"`
Location *LocationContext `json:"location,omitempty"`
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
Alerts *AlertDigestModule `json:"alerts,omitempty"`
}
type SourceWarningSummary struct {
@@ -44,7 +43,6 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}

View File

@@ -265,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
return []ModuleDefinition{
{
ID: module.Metadata,

View File

@@ -373,7 +373,7 @@ func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err)
}
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {

View File

@@ -0,0 +1,37 @@
package briefing
import (
"embed"
"encoding/json"
"fmt"
"strings"
)
//go:embed assets/spc_convective_outlook_definitions.json
var spcConvectiveOutlookDefinitionAssets embed.FS
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
if err != nil {
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
}
var definitions map[string]SPCOutlookBackgroundDefinition
if err := json.Unmarshal(data, &definitions); err != nil {
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
}
return definitions
}
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
if !ok {
return nil
}
return &definition
}
func spcOutlookDefinitionKey(outlookType string, label string) string {
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
}

View File

@@ -25,15 +25,22 @@ type SPCConvectiveOutlooksModule struct {
}
type SPCConvectiveOutlookRecord struct {
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
}
type SPCOutlookBackgroundDefinition struct {
PlainLanguage string `json:"plain_language,omitempty"`
OfficialDescription string `json:"official_description,omitempty"`
RelativeLevel string `json:"relative_level,omitempty"`
}
type SPCConvectiveOutlookDigest struct {
@@ -87,15 +94,16 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
continue
}
records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
})
}
return records

View File

@@ -65,6 +65,12 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.BackgroundDefinition == nil ||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
}
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
}
@@ -83,7 +89,7 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} {
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
@@ -95,6 +101,98 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
}
}
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
tests := []struct {
name string
outlookType string
label string
want bool
}{
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
if tt.want && definition == nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
}
if !tt.want && definition != nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
}
})
}
}
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
outlook.Label = "FOO"
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
if value.Outlooks[0].BackgroundDefinition != nil {
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
}
}
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
wantKeys := []string{
"categorical:TSTM",
"categorical:MRGL",
"categorical:SLGT",
"categorical:ENH",
"categorical:MDT",
"categorical:HIGH",
"tornado:CIG1",
"tornado:CIG2",
"tornado:CIG3",
"wind:CIG1",
"wind:CIG2",
"wind:CIG3",
"hail:CIG1",
"hail:CIG2",
}
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
}
for _, key := range wantKeys {
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
}
}
for key, definition := range spcOutlookBackgroundDefinitions {
if strings.TrimSpace(key) == "" {
t.Fatal("embedded SPC outlook background definitions contain empty key")
}
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
}
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
}
if strings.Contains(definition.OfficialDescription, "by themselves") {
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
}
}
}
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)

View File

@@ -0,0 +1,6 @@
// Package buildinfo exposes release metadata injected by the build pipeline.
package buildinfo
// Version identifies this Weatherreporter build. Release builds replace the
// development value with their semantic version tag through the Go linker.
var Version = "development"

View File

@@ -1,144 +0,0 @@
package changes
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
}
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
}
previousDays := outlookDaysFromDayparts(previousDayparts)
currentDays := outlookDaysFromDayparts(currentDayparts)
return compareOutlookDays(previousDays, currentDays, thresholds, "")
}
type outlookDay struct {
Date string
LowTempF *int
HighTempF *int
MaxPopPercent *int
MaxPopTime string
MaxWindGustMph *int
Indicators indicators
}
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
var changes []Change
for date, currentDay := range currentDays {
previousDay, ok := previousDays[date]
if !ok {
changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
continue
}
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
}
for date := range previousDays {
if _, ok := currentDays[date]; !ok {
changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
}
}
sortChanges(changes)
return changes, nil
}
func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
var changes []Change
for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
change.Message = date + ": " + change.Message
changes = append(changes, change)
}
for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
change.Message = date + ": " + change.Message
changes = append(changes, change)
}
for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
change.Message = date + ": " + change.Message
changes = append(changes, change)
}
for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
change.Message = date + ": " + change.Message
changes = append(changes, change)
}
return changes
}
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
out := map[string]outlookDay{}
var keys []string
for key := range dayparts {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
daypart := dayparts[key]
date := daypartDate(daypart)
if date == "" {
continue
}
day := out[date]
day.Date = date
low, high := parseTempRange(daypart.TempRangeF)
day.LowTempF = minInt(day.LowTempF, low)
day.HighTempF = maxInt(day.HighTempF, high)
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
day.MaxPopTime = daypart.MaxPopTime
}
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
out[date] = day
}
return out
}
func daypartDate(daypart daypartSummaryStanza) string {
return daypart.Date
}
func minInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b < *a {
return copyInt(b)
}
return a
}
func maxInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b > *a {
return copyInt(b)
}
return a
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}

View File

@@ -1,43 +0,0 @@
package changes
import (
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
changes, err := CompareThreeDay(previous, current, Thresholds{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
PrecipTimingShiftMinutes: 120,
})
if err != nil {
t.Fatalf("CompareThreeDay() error = %v", err)
}
if len(changes) == 0 {
t.Fatal("changes length = 0, want detected 3-day changes")
}
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
}
}
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
t.Helper()
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": {
Date: date,
PeriodBegins: date + " at 6:00 AM",
PeriodEnds: date + " at 10:00 AM",
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,
Snow: snow,
},
}})
}

View File

@@ -1,19 +0,0 @@
package changes
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
}
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
}
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
}

View File

@@ -1,19 +0,0 @@
package changes
import "testing"
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
if err != nil {
t.Fatalf("CompareWeekend() error = %v", err)
}
if len(changes) == 0 {
t.Fatal("changes length = 0, want weekend changes")
}
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
t.Fatalf("changes = %#v, want snow risk change", changes)
}
}

View File

@@ -0,0 +1,59 @@
package cli
import (
"time"
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
// PromptExecutorConfig is the project-owned construction input for one prompt
// executor. It keeps adapter implementation types out of Runner's API.
type PromptExecutorConfig struct {
Profile string
ProfileFile string
ProfileDirectory string
Timeout time.Duration
LocalEndpoint string
LocalConcurrencyLimit int
}
// ExecutorFactory constructs one executor for an action.
type ExecutorFactory func(PromptExecutorConfig) (promptexec.Executor, error)
func (r Runner) promptExecutor(cfg config.PromptkitConfig) (promptexec.Executor, error) {
factory := r.ExecutorFactory
if factory == nil {
factory = newPromptkitExecutor
}
return factory(promptExecutorConfig(cfg))
}
func promptExecutorConfig(cfg config.PromptkitConfig) PromptExecutorConfig {
result := PromptExecutorConfig{
Profile: cfg.Profile,
ProfileFile: cfg.ProfileFile,
ProfileDirectory: cfg.ProfileDir,
Timeout: cfg.Timeout,
}
if cfg.Local.Endpoint != "" {
result.LocalEndpoint = cfg.Local.Endpoint
result.LocalConcurrencyLimit = cfg.Local.ConcurrencyLimit
}
return result
}
func newPromptkitExecutor(cfg PromptExecutorConfig) (promptexec.Executor, error) {
return promptkitadapter.New(promptkitAdapterConfig(cfg))
}
func promptkitAdapterConfig(cfg PromptExecutorConfig) promptkitadapter.Config {
return promptkitadapter.Config{
ProfileDirectory: cfg.ProfileDirectory,
ProfileFile: cfg.ProfileFile,
LocalEndpoint: cfg.LocalEndpoint,
LocalConcurrencyLimit: cfg.LocalConcurrencyLimit,
Timeout: cfg.Timeout,
}
}

View File

@@ -0,0 +1,78 @@
package cli
import (
"context"
"testing"
"time"
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestRunnerPromptExecutorMapsConfigurationOnce(t *testing.T) {
var calls int
var received PromptExecutorConfig
runner := Runner{ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) {
calls++
received = value
return factoryExecutor{}, nil
}}
executor, err := runner.promptExecutor(config.PromptkitConfig{
Profile: "selected-profile",
ProfileFile: "/etc/weatherreporter/profile.yml",
Timeout: 45 * time.Second,
Local: config.PromptkitLocalConfig{
Endpoint: "http://127.0.0.1:8080",
ConcurrencyLimit: 3,
},
})
if err != nil || executor == nil || calls != 1 {
t.Fatalf("executor/error/calls = %#v/%v/%d", executor, err, calls)
}
want := PromptExecutorConfig{
Profile: "selected-profile", ProfileFile: "/etc/weatherreporter/profile.yml", Timeout: 45 * time.Second,
LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 3,
}
if received != want {
t.Fatalf("factory config = %#v, want %#v", received, want)
}
}
func TestPromptExecutorConfigLeavesBlankLocalBackendUnregistered(t *testing.T) {
value := promptExecutorConfig(config.PromptkitConfig{
Timeout: 2 * time.Minute,
Local: config.PromptkitLocalConfig{ConcurrencyLimit: 1},
})
if value.LocalEndpoint != "" || value.LocalConcurrencyLimit != 0 {
t.Fatalf("executor config = %#v, want no local backend", value)
}
}
func TestPromptkitAdapterConfigMapsExecutorSettings(t *testing.T) {
adapterConfig := promptkitAdapterConfig(PromptExecutorConfig{
ProfileDirectory: "/etc/weatherreporter/profiles",
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
})
want := promptkitadapter.Config{
ProfileDirectory: "/etc/weatherreporter/profiles",
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
}
if adapterConfig != want {
t.Fatalf("adapter config = %#v, want %#v", adapterConfig, want)
}
}
type factoryExecutor struct{}
func (factoryExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
return promptexec.PromptInspection{}, nil
}
func (factoryExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{}, nil
}
func (factoryExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
return nil, nil
}

View File

@@ -17,26 +17,27 @@ const (
)
type generateSummary struct {
Command string `json:"command"`
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"`
Command string `json:"command"`
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreparationPath string `json:"preparationPath,omitempty"`
ExecutionPath string `json:"executionPath,omitempty"`
LLMDebugPath string `json:"llmDebugPath,omitempty"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"`
}
type generateNotificationSummary struct {
@@ -85,9 +86,10 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
summary.OutputPath = result.OutputPath
summary.MetadataPath = result.MetadataPath
summary.DataPackagePath = result.DataPackagePath
summary.PreflightPath = result.PreflightPath
summary.PreparationPath = result.PreparationPath
summary.ExecutionPath = result.ExecutionPath
summary.LLMDebugPath = result.LLMDebugPath
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
summary.GeneratedTextPath = result.GeneratedTextPath
summary.RenderContextPath = result.RenderContextPath
summary.NotificationPath = result.NotificationPath

View File

@@ -19,16 +19,17 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
startedAt := acceptedAt.Add(time.Minute)
finishedAt := startedAt.Add(time.Minute)
result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml",
PreflightPath: "/runs/hourly/preflight.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
GeneratedTextPath: "/runs/hourly/generated_text.json",
RenderContextPath: "/runs/hourly/render_context.json",
NotificationPath: "/runs/hourly/notification.json",
DataPackagePath: "/runs/hourly/data_package.yaml",
PreparationPath: "/runs/hourly/preparation.json",
ExecutionPath: "/runs/hourly/execution.json",
LLMDebugPath: "/operator-debug/hourly/2026-05-29/run-123",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
GeneratedTextPath: "/runs/hourly/generated_text.json",
RenderContextPath: "/runs/hourly/render_context.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
@@ -58,7 +59,7 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
}
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.LLMDebugPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
}
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
@@ -71,20 +72,23 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
}
if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") {
t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data))
}
}
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/three-day/data_package.yaml",
PreflightPath: "/runs/three-day/preflight.json",
ReportPath: "/runs/three-day/report.md",
OutputPath: "/copies/three-day.md",
MetadataPath: "/runs/three-day/metadata.json",
DataPackagePath: "/runs/daily/data_package.yaml",
PreparationPath: "/runs/daily/preparation.json",
ReportPath: "/runs/daily/report.md",
OutputPath: "/copies/daily.md",
MetadataPath: "/runs/daily/metadata.json",
Metadata: state.Metadata{
ReportID: report.ThreeDay,
PromptID: "weather.three_day_outlook",
RunID: "20260529T133000Z_three_day",
ReportID: report.Daily,
PromptID: "weather.daily_generated_text",
RunID: "20260529T133000Z_daily",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
@@ -92,8 +96,8 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
summary := newGenerateSummary(result, nil)
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful daily summary", summary)
}
if summary.Notification != nil || summary.NotificationPath != "" {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
@@ -102,18 +106,43 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
for _, omitted := range []string{"notification"} {
if strings.Contains(string(data), omitted) {
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
}
}
}
func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) {
result := &app.ReportResult{
DataPackagePath: "/runs/daily/data_package.yaml",
PreparationPath: "/runs/daily/preparation.json",
Metadata: state.Metadata{
ReportID: report.Daily,
RunID: "20260529T133000Z_daily",
},
}
data, err := json.Marshal(newGenerateSummary(result, errors.New("metadata write failed")))
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "generatedTextRawPath", "generatedTextPath", "renderContextPath", "notificationPath"} {
if strings.Contains(text, omitted) {
t.Fatalf("partial summary includes unreached field %q:\n%s", omitted, text)
}
}
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
t.Fatalf("partial summary omits reached paths:\n%s", text)
}
}
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml",
PreflightPath: "/runs/hourly/preflight.json",
PreparationPath: "/runs/hourly/preparation.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",

View File

@@ -7,6 +7,7 @@ import (
"io"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -16,15 +17,13 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage:
weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter --version
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
@@ -34,16 +33,20 @@ Usage:
Options:
-h, --help Show this help message.
--version Show the Weatherreporter version.
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy where supported by the generate command.
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
--out-dir PATH Write extra Markdown report copies for run commands.
--quiet Suppress successful generate and run output.
`
type Runner struct {
Clock timeutil.Clock
Clock timeutil.Clock
ExecutorFactory ExecutorFactory
Version string
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
@@ -58,6 +61,17 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
_, err := fmt.Fprint(stdout, helpText)
return err
}
if args[0] == "--version" {
if len(args) != 1 {
return fmt.Errorf("--version does not accept arguments")
}
version := r.Version
if version == "" {
version = buildinfo.Version
}
_, err := fmt.Fprintf(stdout, "weatherreporter %s\n", version)
return err
}
switch args[0] {
case "generate":
@@ -99,19 +113,18 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
}
type commonOptions struct {
ConfigPath string
Units string
Timezone string
Output string
OutputDir string
Quiet bool
ConfigPath string
Units string
Timezone string
Output string
OutputDir string
LLMDebugDir string
Quiet bool
}
type generateOptions struct {
commonOptions
Date string
Start string
End string
Date string
}
type inspectOptions struct {
@@ -218,16 +231,22 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
executor, err := r.promptExecutor(cfg.Promptkit)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
OutputPath: opts.Output,
Now: r.Clock.Now(),
Config: cfg,
Report: reportKind,
OutputPath: opts.Output,
LLMDebugDir: opts.LLMDebugDir,
Now: r.Clock.Now(),
Executor: executor,
}
switch reportKind {
@@ -248,19 +267,6 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
return app.GenerateRequest{}, commonOptions{}, err
}
}
case app.ReportStorm:
if opts.Start == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --start")
}
if opts.End == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --end")
}
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req.StormStart = period.Start
req.StormEnd = period.End
}
return req, opts.commonOptions, nil
@@ -294,7 +300,11 @@ func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil
executor, err := r.promptExecutor(cfg.Promptkit)
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
}
func resolveRun(args []string) (app.BatchRequest, error) {
@@ -310,10 +320,6 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
if report == app.ReportStorm {
fs.StringVar(&opts.Start, "start", "", "storm start time")
fs.StringVar(&opts.End, "end", "", "storm end time")
}
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
@@ -376,6 +382,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
}

File diff suppressed because it is too large Load Diff

42
internal/cli/run_test.go Normal file
View File

@@ -0,0 +1,42 @@
package cli
import (
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) {
opts, err := parseRunFlags([]string{"--llm-debug-dir", "/tmp/prompt-debug"})
if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" {
t.Fatalf("parseRunFlags() = %#v, %v", opts, err)
}
}
func TestResolveRunActionConstructsOneExecutor(t *testing.T) {
for _, command := range []string{"morning", "evening"} {
t.Run(command, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
calls := 0
executor := &factoryExecutor{}
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
calls++
return executor, nil
},
}
req, _, err := runner.resolveRunAction([]string{command, "--config", configPath, "--llm-debug-dir", "/tmp/debug"})
if err != nil || calls != 1 || req.Executor != executor || req.LLMDebugDir != "/tmp/debug" {
t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls)
}
})
}
}

View File

@@ -27,7 +27,7 @@ type Config struct {
Secrets SecretsConfig `yaml:"secrets"`
Notify NotifyConfig `yaml:"notify"`
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Promptkit PromptkitConfig `yaml:"promptkit"`
Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
@@ -81,12 +81,17 @@ type MissingSourceConfig struct {
Sources map[string]MissingSourcePolicy `yaml:"sources"`
}
type ScriptoriumConfig struct {
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Profile string `yaml:"profile"`
Timeout time.Duration `yaml:"timeout"`
ExtraArgs []string `yaml:"extra_args"`
type PromptkitConfig struct {
Profile string `yaml:"profile"`
ProfileFile string `yaml:"profile_file"`
ProfileDir string `yaml:"profile_dir"`
Timeout time.Duration `yaml:"timeout"`
Local PromptkitLocalConfig `yaml:"local"`
}
type PromptkitLocalConfig struct {
Endpoint string `yaml:"endpoint"`
ConcurrencyLimit int `yaml:"concurrency_limit"`
}
type WorkspaceConfig struct {

View File

@@ -28,6 +28,9 @@ func TestDefaults(t *testing.T) {
if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
}
if cfg.WeatherAPI.Precision != 0 {
t.Fatalf("Precision = %d, want 0", cfg.WeatherAPI.Precision)
}
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
}
@@ -141,8 +144,8 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units)
}
if cfg.Scriptorium.Binary != "scriptorium" {
t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary)
if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
}
if cfg.Workspace.Root != "workspace" {
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
@@ -155,6 +158,13 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
}
}
func TestLoadRejectsRetiredExecutionConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "scriptorium:\n binary: scriptorium\n"))
if err == nil || !strings.Contains(err.Error(), "migrate to promptkit") {
t.Fatalf("LoadFile() error = %v, want actionable migration error", err)
}
}
func TestLoadReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
@@ -272,39 +282,6 @@ reports:
}
}
func TestLoadReportModuleOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
deterministic_modules:
- metadata
weekend_outlook:
deterministic_modules:
- metadata
storm_report:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
}
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
}
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
}
}
func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
@@ -372,35 +349,6 @@ reports:
}
}
func TestLoadReportDistributorPathOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
weekend_outlook:
distributor:
path_templates:
- "weekend/{valid_start_date}/index.md"
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) {
t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay])
}
if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) {
t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend])
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
@@ -680,21 +628,6 @@ reports:
`,
wantErr: `unknown report distributor field "paths"`,
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
three_day:
distributor:
path_templates:
- "three-day/latest.md"
`,
wantErr: "duplicates report override",
},
{
name: "UnknownTemplateVariable",
yaml: `
@@ -751,17 +684,6 @@ reports:
`,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
},
{
name: "NonStormStormIDEmptyPathSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/{storm_id}/index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments",
},
{
name: "EmptyOverrideList",
yaml: `
@@ -787,23 +709,6 @@ reports:
}
}
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/storm-{storm_id}.md"
storm:
distributor:
path_templates:
- "storm/{storm_id}/index.md"
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
}
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := `
reports:
@@ -866,29 +771,6 @@ reports:
},
wantErr: "reports.moon",
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
deterministic_modules:
- metadata
three_day:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"three-day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
"three_day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "duplicates report override",
},
{
name: "UnknownModule",
yaml: `
@@ -1386,38 +1268,37 @@ func TestDistributorTemplateRendering(t *testing.T) {
ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600",
StormID: "2026-06-07T1800-2026-06-08T0600",
BundleID: "weatherreporter.home.daily",
}
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values)
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{valid_start_date}", values)
if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
if bundleID != "weatherreporter.home.daily.2026-06-07" {
t.Fatalf("bundleID = %q, want rendered value", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values)
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{valid_start_stamp}.{bundle_id}", values)
if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
if pipelineID != "weatherreporter.daily.2026-06-07T1800.weatherreporter.home.daily.2026-06-07" {
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
}
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values)
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{valid_end_stamp}.{run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" {
if idempotencyKey != "weatherreporter.home.daily.2026-06-07.2026-06-08T0600.20260607T120000Z" {
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
}
reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
"storm/{storm_id}/index.md",
"daily/{valid_start_date}/index.md",
"{valid_start_date}/{artifact_group}/latest.md",
}, values)
if err != nil {
@@ -1425,7 +1306,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
}
wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
"storm/2026-06-07T1800-2026-06-08T0600/index.md",
"daily/2026-06-07/index.md",
"2026-06-07/daily/latest.md",
}
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {

View File

@@ -8,7 +8,7 @@ func Defaults() Config {
return Config{
WeatherAPI: WeatherAPIConfig{
Timeout: 10 * time.Second,
Precision: 1,
Precision: 0,
Units: "us",
Timezone: "America/Chicago",
Format: "json",
@@ -43,9 +43,11 @@ func Defaults() Config {
Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{},
},
Scriptorium: ScriptoriumConfig{
Binary: "scriptorium",
Promptkit: PromptkitConfig{
Timeout: 2 * time.Minute,
Local: PromptkitLocalConfig{
ConcurrencyLimit: 1,
},
},
Workspace: WorkspaceConfig{
Root: "workspace",

View File

@@ -59,6 +59,9 @@ func mergeFile(cfg *Config, path string) error {
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
if err := rejectRetiredExecutionConfig(data); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
@@ -70,3 +73,20 @@ func mergeFile(cfg *Config, path string) error {
}
return nil
}
func rejectRetiredExecutionConfig(data []byte) error {
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
return err
}
if len(document.Content) == 0 || document.Content[0].Kind != yaml.MappingNode {
return nil
}
root := document.Content[0]
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value == "scriptorium" {
return fmt.Errorf("scriptorium configuration is no longer supported; migrate to promptkit configuration")
}
}
return nil
}

View File

@@ -18,7 +18,6 @@ type DistributorTemplateValues struct {
ValidEndTime string
ValidStartStamp string
ValidEndStamp string
StormID string
BundleID string
}
@@ -42,7 +41,6 @@ var distributorTemplateVariables = map[string]struct{}{
"valid_end_time": {},
"valid_start_stamp": {},
"valid_end_stamp": {},
"storm_id": {},
}
var distributorIdempotencyTemplateVariables = map[string]struct{}{
@@ -57,7 +55,6 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
"valid_end_time": {},
"valid_start_stamp": {},
"valid_end_stamp": {},
"storm_id": {},
"bundle_id": {},
}
@@ -246,8 +243,6 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
return values.ValidStartStamp
case "valid_end_stamp":
return values.ValidEndStamp
case "storm_id":
return values.StormID
case "bundle_id":
return values.BundleID
default:

View File

@@ -0,0 +1,101 @@
package config
import (
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestPromptkitDefaultsAndYAML(t *testing.T) {
cfg := Defaults()
if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
}
if err := yaml.Unmarshal([]byte(`
promptkit:
profile: selected
profile_file: /etc/weatherreporter/profile.yml
timeout: 45s
local:
endpoint: http://127.0.0.1:8080
concurrency_limit: 0
`), &cfg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if cfg.Promptkit.Profile != "selected" || cfg.Promptkit.ProfileFile != "/etc/weatherreporter/profile.yml" || cfg.Promptkit.Timeout != 45*time.Second || cfg.Promptkit.Local.Endpoint != "http://127.0.0.1:8080" || cfg.Promptkit.Local.ConcurrencyLimit != 0 {
t.Fatalf("Promptkit YAML = %#v", cfg.Promptkit)
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidatePromptkit(t *testing.T) {
tests := []struct {
name string
mutate func(*PromptkitConfig)
wantErr string
}{
{
name: "profile sources conflict",
mutate: func(cfg *PromptkitConfig) {
cfg.ProfileFile = "profile.yml"
cfg.ProfileDir = "profiles"
},
wantErr: "profile_file",
},
{
name: "nonpositive timeout",
mutate: func(cfg *PromptkitConfig) {
cfg.Timeout = 0
},
wantErr: "timeout",
},
{
name: "invalid local endpoint",
mutate: func(cfg *PromptkitConfig) {
cfg.Local.Endpoint = "not a URL"
},
wantErr: "local.endpoint",
},
{
name: "negative local concurrency",
mutate: func(cfg *PromptkitConfig) {
cfg.Local.ConcurrencyLimit = -1
},
wantErr: "concurrency_limit",
},
{
name: "unlimited local concurrency",
mutate: func(cfg *PromptkitConfig) {
cfg.Local.Endpoint = "http://127.0.0.1:8080"
cfg.Local.ConcurrencyLimit = 0
},
},
{
name: "unregistered local backend",
mutate: func(cfg *PromptkitConfig) {
cfg.Local.Endpoint = ""
cfg.Local.ConcurrencyLimit = 1
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := Defaults()
test.mutate(&cfg.Promptkit)
err := Validate(cfg)
if test.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
}
})
}
}

View File

@@ -128,14 +128,10 @@ func validateReportDistributorPathTemplates(reportKey string, reportID report.ID
}
func sampleDistributorTemplateValues() DistributorTemplateValues {
return sampleDistributorTemplateValuesForReport(report.Storm)
return sampleDistributorTemplateValuesForReport(report.Daily)
}
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
stormID := ""
if reportID == report.Storm {
stormID = "2026-05-29T0000-2026-05-30T0000"
}
func sampleDistributorTemplateValuesForReport(_ report.ID) DistributorTemplateValues {
return DistributorTemplateValues{
LocationID: "location",
ReportID: "report",
@@ -148,7 +144,6 @@ func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTem
ValidEndTime: "0000",
ValidStartStamp: "2026-05-29T0000",
ValidEndStamp: "2026-05-30T0000",
StormID: stormID,
}
}

View File

@@ -59,11 +59,8 @@ func Validate(cfg Config) error {
return err
}
if cfg.Scriptorium.Binary == "" {
return fmt.Errorf("scriptorium.binary is required")
}
if cfg.Scriptorium.Timeout <= 0 {
return fmt.Errorf("scriptorium.timeout must be greater than zero")
if err := validatePromptkit(cfg.Promptkit); err != nil {
return err
}
if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required")
@@ -85,6 +82,25 @@ func Validate(cfg Config) error {
return nil
}
func validatePromptkit(cfg PromptkitConfig) error {
if cfg.ProfileFile != "" && cfg.ProfileDir != "" {
return fmt.Errorf("promptkit.profile_file and promptkit.profile_dir cannot both be configured")
}
if cfg.Timeout <= 0 {
return fmt.Errorf("promptkit.timeout must be greater than zero")
}
if cfg.Local.Endpoint != "" {
parsed, err := url.Parse(cfg.Local.Endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("promptkit.local.endpoint must be an absolute URL when configured")
}
}
if cfg.Local.ConcurrencyLimit < 0 {
return fmt.Errorf("promptkit.local.concurrency_limit must be zero or greater")
}
return nil
}
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if !cfg.Enabled {
return nil

View File

@@ -82,7 +82,6 @@ type DerivedFacts struct {
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming
StormWindowSummary *forecast.DaypartSummary
}
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
@@ -121,16 +120,6 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
return DerivedFacts{}, err
}
derived.DailySummaries = []forecast.DailySummary{*summary}
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = summaries
case report.Storm:
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
summary.AlertOverlaps = derived.AlertOverlaps
derived.StormWindowSummary = &summary
default:
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
}
@@ -144,9 +133,6 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
for _, summary := range derived.DailySummaries {
out = append(out, summary.Dayparts...)
}
if derived.StormWindowSummary != nil {
out = append(out, *derived.StormWindowSummary)
}
return out
}

View File

@@ -100,42 +100,9 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
}
}
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
func TestBuildDerivedTomorrow(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.Narrative = nil
bundle.Alerts = nil
bundle.Discussion = nil
bundle.WeatherStory = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.DailySummaries) != 3 {
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
}
if len(derived.ValidPeriodNarrativePeriods) != 0 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.AlertOverlaps) != 0 {
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
}
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
}
}
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation()
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
for _, id := range []report.ID{report.Tomorrow} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
@@ -183,8 +150,8 @@ func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 {
t.Fatalf("hourly summaries daily=%#v daypart=%#v, want none", derived.DailySummaries, derived.DaypartSummaries)
}
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
@@ -209,36 +176,6 @@ func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation()
resolved := resolveStormForTest(t, location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 2 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if len(derived.ValidPeriodDailyPeriods) != 1 {
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
}
if derived.StormWindowSummary == nil {
t.Fatal("StormWindowSummary = nil, want summary")
}
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
}
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
}
}
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
location := testLocation()
now := mustParse("2026-05-29T08:00:00-05:00")
@@ -263,24 +200,6 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"},
},
{
name: "three day",
resolved: resolveForTest(t, report.ThreeDay, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
},
{
name: "weekend",
resolved: resolveForTest(t, report.Weekend, now, location),
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day2", "day3"},
},
{
name: "storm",
resolved: resolveStormForTest(t, location),
wantOutlookIDs: []string{"fri-storm", "fri-low"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
}
for _, tt := range tests {
@@ -476,20 +395,6 @@ func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Lo
return resolved
}
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T08:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
return resolved
}
func testLocation() *time.Location {
location, err := time.LoadLocation("America/Chicago")
if err != nil {

View File

@@ -217,62 +217,6 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim
return summary, nil
}
func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required")
}
if location == nil {
location = time.UTC
}
var summaries []DailySummary
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
overlap, ok := day.Intersection(period)
if !ok {
continue
}
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
if err != nil {
return nil, err
}
summaries = append(summaries, *summary)
}
return summaries, nil
}
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
return nil, fmt.Errorf("hourly forecast data is required")
}
windows, err := ResolveDayparts(period.Start, location, dayparts)
if err != nil {
return nil, err
}
alerts := AlertOverlaps(bundle.Alerts, period)
summary := &DailySummary{
Date: period.Start.In(location).Format(timeutil.DateLayout),
Period: period,
NarrativePeriods: SelectNarrativePeriods(bundle, period),
AlertOverlaps: alerts,
Discussion: SelectDiscussion(bundle),
SourceWarnings: bundle.Warnings,
SourceProvenance: bundle.Sources,
}
for _, window := range windows {
clipped, ok := window.Period.Intersection(period)
if !ok {
continue
}
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
summary.Dayparts = append(summary.Dayparts, daypartSummary)
}
return summary, nil
}
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil {
return nil

View File

@@ -154,41 +154,6 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
}
}
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
}}}
period := timeutil.Period{
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
}
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
})
if err != nil {
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
}
if len(summaries) != 3 {
t.Fatalf("summaries length = %d, want 3", len(summaries))
}
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
}
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
}
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
}
}
func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
)
@@ -69,9 +70,6 @@ var catalog = []catalogEntry{
}
func LookupDefinition(definition report.Definition) (Handler, error) {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
}
var schemaKnown, templateKnown bool
for _, entry := range catalog {
@@ -109,7 +107,7 @@ func (h Handler) TemplateID() string {
}
func (h Handler) Schema() ([]byte, error) {
data, err := reporttemplate.Schema(h.schemaID)
data, err := promptassets.Schema(h.schemaID)
if err != nil {
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
}

View File

@@ -9,9 +9,6 @@ import (
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
for _, definition := range report.DefaultRegistry().All() {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
continue
}
t.Run(string(definition.ID), func(t *testing.T) {
handler, err := LookupDefinition(definition)
if err != nil {
@@ -96,7 +93,6 @@ func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
definition := report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
}
@@ -122,7 +118,6 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
definition := report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
}
@@ -184,7 +179,6 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
@@ -207,7 +201,6 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
@@ -262,7 +255,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
@@ -282,7 +274,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
@@ -304,7 +295,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
handler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})

View File

@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
@@ -99,6 +99,9 @@ func TestBuildHourlyRenderContext(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "Avoid low-water crossings.") {
t.Fatalf("rendered template includes alert instruction:\n%s", text)
}
}
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {

View File

@@ -0,0 +1,81 @@
Your task is to generate a local weather forecast analysis from the following YAML data package, which is prepared by the weatherreporter application.
Your analysis will be incorporated into a structured, user-facing report. The report may be for today, tomorrow, or a future date. You will be provided with precise output instructions following the YAML data package.
# SOURCE ROLES AND WEIGHTING
Use `report` and `briefing.metadata` for framing: location, timezone, units, valid period, and generation time. Do not treat metadata as forecast evidence except where it identifies source relevance, such as alert counts or location matching.
For weather interpretation, think in four source layers, in this order:
## 1. Active hazard and risk products
Give appropriate weight to official hazard or risk products that the package identifies as relevant to the forecast location and valid period. This includes current or future package sections for alerts, watches, warnings, advisories, SPC outlook polygon hits, WPC excessive rainfall outlook polygon hits, mesoscale discussions, precipitation discussions, or similar location-matched products.
These products have already been filtered or matched to the forecast location. Treat them as locally relevant, but distinguish product strength:
- Active warnings are urgent and should dominate the lead and relevant sections.
- Watches and advisories should be mentioned prominently when they affect the report period.
- Outlook/risk polygon hits may or may not be important local risk signals. Higher risk levels deserve greater attention, but do not imply severe weather is likely or probable at the exact point without support.
- Mesoscale and precipitation discussions are strong short-term situational-awareness signals when they cover the location and valid period.
For the current schema, use `briefing.applicable_risk_products.alert_digest` and `briefing.metadata.alerts` to determine whether relevant local alerts exist. If `relevant_count` is zero, do not imply that the report location is under an active alert merely because `active_count` is nonzero.
## 2. Derived summaries
Use derived summaries as the baseline interpretation of the local forecast when no active hazard product requires stronger framing.
- Use `briefing.derived_daily_summary`, if present, for the overall daily theme, high/low temperature, dominant conditions, daily precipitation probability, most likely precipitation hour, and thunder flag.
- Use `briefing.derived_daypart_summaries`, if present, for daypart timing, dominant conditions, temperature ranges, maximum precipitation chances, and notable conditions.
- Use `briefing.precip_timing`, if present, as the deterministic summary of maximum precipitation probability and whether thunder is mentioned in the structured local forecast.
- Use `briefing.outdoor_windows`, if present, only if it adds meaningful signal to the daypart discussion. Do not turn the report into outdoor-planning advice.
## 3. Narrative products
Use `briefing.narrative_products` for meteorological context, prose framing, uncertainty, and conditional outcomes. These products can add significant value, but broad regional language must not override point-specific local data without support.
- Use `briefing.narrative_products.narrative_forecast.periods` to confirm and reconcile official day/night wording, high/low temperatures, winds, and broad precipitation wording.
- Use `briefing.narrative_products.weather_story` and `briefing.narrative_products.area_forecast_discussion.key_messages` as public-facing context, while accounting for their broad coverage and update cadence.
- Use `briefing.narrative_products.area_forecast_discussion.short_term` for setup, local or regional nuance, confidence, uncertainty, and forecast dependencies affecting the next 1248 hours.
- Use `briefing.narrative_products.area_forecast_discussion.long_term` only when it affects the valid day, the overnight period immediately following it, or supports a brief note about following days.
- Use `briefing.narrative_products.spc_convective_discussion.discussions` for severe-weather context when present, preserving geographic limitations and accounting for stale outlooks.
## 4. Raw underlying data
Use `briefing.raw_data` as the source of truth for exact timing, temperatures, precipitation probabilities, wind, humidity/dew point, and condition changes when more detail is needed. `briefing.raw_data.hourly_forecast.periods` is the most granular local forecast source. Use `briefing.raw_data.current_conditions` only as generation-time context.
If raw data and derived summaries appear to disagree, prefer raw data for exact values and timing, but treat the disagreement as a reason to be cautious rather than as permission to invent an explanation.
# CONFLICT RESOLUTION
When sources differ, ask:
1. Which source is most local to the forecast point?
2. Which source is valid for the report period or near-term window?
3. Which source is most authoritative for the type of claim being made?
4. Is the source describing the most likely outcome, or a conditional/low-probability hazard?
Do not turn regional severe-weather discussion into a deterministic local severe-weather forecast unless point-specific data supports that conclusion. Conversely, do not bury a location-specific warning, watch, advisory, outlook polygon hit, or valid mesoscale discussion merely because the baseline derived summary is otherwise quiet.
# HAZARD AND PRECIPITATION RULES
Mention a hazard only to the extent supported by location-specific products, local structured forecast data, or clearly applicable narrative text. Preserve product strength, uncertainty, geography, and timing. Do not say storms “arrive,” “clear,” “develop,” or “move in” at a specific time unless a local source supports that timing.
Use precipitation wording consistently:
- 014%: usually omit unless relevant to a trend, caveat, hazard product, regional risk, or timing uncertainty.
- 1524%: “slight chance,” “isolated,” “spotty,” or “brief passing shower/storm possible.”
- 2539%: “chance,” “scattered,” or “some showers/storms possible.”
- 4059%: “good chance” or “showers/storms likely enough to plan around.”
- 60%+: “likely,” “wet,” or “unsettled,” if consistent with the narrative forecast.
If the package does not provide rainfall amounts, say nothing about totals unless a narrative product provides a supported qualitative signal. Do not invent QPF. If local precipitation chances are low and no meaningful local impacts are expected, do not imply thunderstorms are likely solely because regional precipitation or severe weather appears in narrative text.
# STYLE RULES
- Plainspoken, precise, and weather-literate.
- Compact, but not shallow.
- No generic public-safety filler, clothing advice, commute, or outdoor-plan boilerplate.
- No unsupported precision or apologies for missing data.
- Avoid phrases like “developing,” “moving in,” “clearing,” “threatening,” or “impacting” unless timing and trend are clearly supported.
- Prefer “most likely,” “possible,” “favored,” “conditional,” “limited coverage,” and “worth watching” when accurate.

View File

@@ -0,0 +1,9 @@
You are WeatherReporter, a concise personal weather briefing writer.
You generate local weather forecast analysis from structured data packages prepared by the weatherreporter application.
Use only the provided data package as your source of truth. Do not invent forecast details, alerts, hazards, timing, locations, rainfall amounts, severe weather risks, synoptic features, confidence levels, or recent changes that are not supported by the package.
The reader is intelligent and weather-literate, but not a professional meteorologist. If asked to provide narrative analysis or commentary, write in plain, precise, meteorologically informed language. Avoid hype, filler, generic safety advice, and TV-weather style. Provide polished prose that avoids highly technical meteorological jargon or shorthand.
Do not mention that you are an AI model.

View File

@@ -0,0 +1,34 @@
TASK: You are writing structured prose slots for a daily weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data. The report focuses on the upcoming civil day in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. One or two sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# Summary
The summary should typically consist of two sentences. If an active warning is relevant during the report period, lead with the hazard. Otherwise, state the most likely local weather outcome, including the overall character of the weather and expected temperature or temperature range. The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome when one exists.
Distinguish the main weather outcome from its caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as one risk throughout the period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
# Forecast discussion
Use narrative products to explain the “why” behind the local forecast when useful. Useful context may include synoptic pattern, fronts or boundaries, shortwaves, troughs or ridges, instability, moisture, shear, forcing, capping, regional placement of precipitation or severe-weather chances, hazards, timing windows, confidence, uncertainty, conditional outcomes, and relevant notes about following days.
In most cases, include three paragraphs: a two-to-four sentence relevant local or regional setup; a two-to-four sentence main uncertainty or conditional factor when present; and a two-to-four sentence next-day or broader-pattern note when supported.
# Precipitation timing
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration.
# Narrative source selection
Use `briefing.derived_daily_summary`, `briefing.derived_daypart_summaries`, `briefing.narrative_products.narrative_forecast.periods`, and `briefing.raw_data.hourly_forecast.periods` as primary sources. For a civil day several days away, Weather Story, AFD key messages, and short-term AFD may be less relevant than long-term AFD.

Some files were not shown because too many files have changed in this diff Show More