61 Commits

Author SHA1 Message Date
af9cb0c0dc Document Weatherreporter v0.11.0
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-02 02:09:47 +00:00
20c82776dc Finish output directory follow-up work 2026-08-02 02:08:30 +00:00
f364ce773d Complete configurable output directory implementation 2026-08-02 01:42:58 +00:00
0dc6a06cd3 Document configured output directories 2026-08-02 01:41:03 +00:00
2af6a5cfd2 Apply configured output directories 2026-08-02 01:38:06 +00:00
0c4c575eea Add output directory configuration contract 2026-08-02 01:34:23 +00:00
114f7f5f85 Make release validation portable
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-02 00:36:54 +00:00
328c7a5693 Document Weatherreporter v0.10.0
Some checks failed
ci/woodpecker/tag/release Pipeline failed
2026-08-02 00:29:36 +00:00
fe176a2abc Finish stateless execution cleanup 2026-08-02 00:15:41 +00:00
ab9218b124 Complete stateless execution remediation 2026-08-01 22:01:28 +00:00
8d6ab0eb56 Remove per-report batch notification state 2026-08-01 21:56:25 +00:00
76cd399c76 Keep batch notification failures out of report counts 2026-08-01 21:54:53 +00:00
bf1746a756 Preflight batch output destinations 2026-08-01 21:51:42 +00:00
28bdc04fba Prevent output publication after cancellation 2026-08-01 21:49:18 +00:00
b67fae886e Complete stateless execution exit gate 2026-08-01 20:18:54 +00:00
71a2eae87b Reconcile internal stateless documentation 2026-08-01 20:16:47 +00:00
bd34ec57f8 Document stateless output operations 2026-08-01 20:11:57 +00:00
97215ddb9b Expand stateless workflow test coverage 2026-08-01 20:06:40 +00:00
dd7881acfb Remove workspace state subsystem 2026-08-01 20:00:54 +00:00
ece31567b8 Remove historical inspection commands 2026-08-01 19:58:11 +00:00
7ffc3dc603 Run report generation without workspace state 2026-08-01 19:52:22 +00:00
4bdba6f2b7 Stop persisting notification receipts 2026-08-01 19:40:51 +00:00
b184ca7cbd Move prompt debug capture out of state 2026-08-01 19:35:12 +00:00
62a12dd661 Write reports to operator-selected outputs 2026-08-01 19:33:12 +00:00
ac8d618111 Remove dormant forecast comparison policy 2026-08-01 19:24:34 +00:00
5ddd3ee19c Remove recent changes from prompt execution 2026-08-01 19:22:11 +00:00
8be9b020d4 Record stateless execution architecture decision 2026-08-01 19:18:45 +00:00
7f5a9c0357 Plan the stateless execution refactor 2026-08-01 19:16:44 +00:00
7d591487e4 Clean up roadmap and troubleshooting documentation 2026-08-01 18:16:01 +00:00
1250247986 Correct profile test boundaries and fallback coverage 2026-08-01 17:24:21 +00:00
117c5336ba Finalize domain prompt profile roadmap 2026-08-01 14:27:39 +00:00
c5ec4f83b2 Document logical prompt profile configuration 2026-08-01 14:25:09 +00:00
39c097a710 Verify profile selection in application workflows 2026-08-01 14:20:57 +00:00
993120a9f2 Adopt logical prompt profile defaults 2026-08-01 14:16:55 +00:00
c20e285d5f Wire embedded profile fallbacks 2026-08-01 14:14:36 +00:00
acbe22dcad Add embedded weather profile catalog 2026-08-01 14:13:11 +00:00
cc97ae186c Plan domain profiles and ephemeral state 2026-08-01 14:07:23 +00:00
51c35f7c22 Upgrade Promptkit to version 0.5.0 2026-08-01 13:38:18 +00:00
f014a078ee Plan domain-specific prompt profiles 2026-08-01 02:15:45 +00:00
8c19ad763b Require precipitation timing in generated text 2026-08-01 01:25:57 +00:00
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
176 changed files with 7220 additions and 15235 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Compiled application binary and testing workspace # Compiled application binary
/weatherreporter /weatherreporter
/workspace
# ---> Go # ---> Go
# If you prefer the allow list template instead of the deny list, see community template: # If you prefer the allow list template instead of the deny list, see community template:

View File

@@ -2,8 +2,50 @@ when:
- event: tag - event: tag
steps: 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 - name: build-release-assets
image: golang:1.25 image: golang:1.26.5
depends_on:
- validate-release
commands: commands:
- | - |
set -eu set -eu
@@ -33,8 +75,11 @@ steps:
build_binary windows amd64 ".exe" build_binary windows amd64 ".exe"
build_binary windows arm64 ".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 - name: publish-release
image: woodpeckerci/plugin-release image: woodpeckerci/plugin-release:0.3.1
depends_on: depends_on:
- build-release-assets - build-release-assets
settings: settings:
@@ -42,6 +87,8 @@ steps:
from_secret: GITEA_RELEASE_TOKEN from_secret: GITEA_RELEASE_TOKEN
files: files:
- dist/weatherreporter-* - dist/weatherreporter-*
title: Weatherreporter ${CI_COMMIT_TAG}
note: docs/releases/${CI_COMMIT_TAG}.md
checksum: sha256 checksum: sha256
checksum-file: SHA256SUMS checksum-file: SHA256SUMS
checksum-flatten: true checksum-flatten: true

View File

@@ -1,25 +1,28 @@
# weatherreporter # weatherreporter
Weatherreporter is a Go CLI that turns normalized weather data into managed, Weatherreporter is a Go CLI that turns normalized weather data into
human-facing Markdown reports. human-facing Markdown reports.
It provides repeatable reports with inspectable local artifacts, so operators It produces a Markdown report at an operator-owned destination and can upload
can review what was collected and generated for every run. the completed output through Distributor.
## Quickstart ## Quickstart
```sh ```sh
weatherreporter generate today --out ./today.md weatherreporter generate today
``` ```
Configure a Weather API endpoint first; see the Configure a Weather API endpoint first; see the
[configuration reference](docs/config.md). [configuration reference](docs/config.md). The report is written to
`today.md` in the current directory when `output.directory` is not configured.
Set that configuration value for an ordinary publication directory, or use
`--out` for one command. See the [CLI reference](docs/cli.md) and [operations
guide](docs/operations.md) for command and operating details.
## Documentation ## Documentation
- [CLI reference](docs/cli.md) - [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md) - [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md) - [Operations guide](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Development guide](docs/development.md) - [Development guide](docs/development.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)

View File

@@ -0,0 +1,100 @@
# 0001: Make Weatherreporter Execution Stateless
Status: Accepted
Date: 2026-08-01
## Context
Weather reports are ephemeral products. Forecasts and current conditions change
continuously, so the useful response to an old, failed, or superseded report is
normally a new generation rather than replaying or inspecting a prior run.
The existing run-addressed workspace retains module snapshots, prompt inputs,
execution receipts, generated text, rendered reports, metadata, and
notification receipts. That provenance store accumulates operational history
whose recovery and compatibility obligations are disproportionate to the value
of an ephemeral weather report. It also exists solely to support local Recent
Changes comparison for a rarely used report section.
The temporary roadmap that defined the feature scope and implementation plan
has been retired under the repository's documentation lifecycle. The
[architecture policy](../policy/architecture.md) defines the resulting system
invariants; this decision records their durable rationale.
## Decision
Weatherreporter will operate as a stateless transformation pipeline:
```text
Weather API input
-> deterministic facts and modules
-> Promptkit data package and generated text
-> repository-owned Markdown rendering
-> operator-owned report output
-> optional Distributor upload
```
Ordinary invocations will retain intermediate values only for the active
process and will publish one operator-owned Markdown output atomically. A
failed or canceled generation must not truncate or partially replace an
existing selected output. Single-report Distributor notification follows
successful publication; batch notification follows successful publication of
every planned report.
Weatherreporter will remove local Recent Changes comparison instead of
retaining application state to support it. It will remove run-addressed
workspace artifacts, historical inspection, and backward-compatible workspace
decoding. RunIDs may remain active correlation and Distributor idempotency
values, but will not identify retained application history.
Explicit `--llm-debug-dir` capture remains the sole diagnostic-file exception.
The operator selects and manages that secure location; ordinary execution does
not create an implicit debug location or a general logging store, and debug
capture must continue to exclude credentials.
Any future forecast comparison must use a structured product supplied by the
Weather API rather than local Weatherreporter history. The proposed
[Upstream Forecast Change Product](../roadmap/future.md#upstream-forecast-change-product)
defines the required upstream direction. A future integration must not add a
local snapshot fallback.
## Alternatives Considered
### Retain The Bounded Current-State Design
Retaining a managed workspace with current metadata, receipts, and snapshots
would preserve inspection and local comparison, but keeps an application-owned
history subsystem, artifact compatibility burden, and recovery surface that do
not match the report lifecycle.
### Time-Based Retention
Expiring workspace material after a fixed period reduces accumulation but still
requires retention policy, cleanup behavior, failure handling, and historical
format support. It does not remove the mismatch between retained provenance and
ephemeral report products.
### Bounded Run History
Keeping only a fixed number of prior runs limits storage volume but still makes
Weatherreporter responsible for run selection, comparison, inspection, and
state migration. It also creates arbitrary history gaps without establishing an
authoritative forecast baseline.
## Consequences
The CLI, configuration, prompt-input, workspace, and inspection contracts will
change together. Legacy workspace material will not be migrated, decoded, or
automatically deleted; operators remain responsible for any desired cleanup.
Current action results will carry active identity, selected profile, safe
effective model information, output location, notification result, and safe
errors instead of historical artifact paths. Tests will protect atomic output,
batch and notification ordering, explicit secure debug capture, and the
absence of ordinary application-managed state.
This decision deliberately leaves the Weather API responsible for any future
forecast-history comparison. It avoids a cache, archive, retention engine,
manifest, resume mechanism, or replacement inspection surface in
Weatherreporter.

View File

@@ -1,106 +1,115 @@
# Weatherreporter CLI # Weatherreporter CLI
`weatherreporter` generates weather reports, runs report batches, and inspects `weatherreporter` generates Markdown weather reports and runs report batches.
artifacts already stored in its workspace. It has no command for inspecting prior runs or application-owned state.
## Shortest Useful Command ## Shortest Useful Command
```sh ```sh
weatherreporter generate today --out ./today.md weatherreporter generate today
``` ```
The command uses the configured Weather API and writes an extra Markdown copy The command uses the configured Weather API and atomically writes `today.md`.
at `./today.md`. See the [configuration reference](config.md) to supply the With no configured output directory, it writes in the current directory. See
required Weather API endpoint. the [configuration reference](config.md) to supply the required Weather API
endpoint and choose an ordinary output directory.
## Commands And Usage ## Commands And Usage
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter --version
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
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 inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
`weatherreporter --version` prints the version embedded in the executable.
Tagged release binaries report their semantic version tag; ordinary local
builds report `development`.
| Command | Contract | | Command | Contract |
| --- | --- | | --- | --- |
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. | | `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. Its default filename is `daily-YYYY-MM-DD.md`. |
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. | | `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. Its default filename is `today.md`. |
| `generate tomorrow`, `three-day`, `weekend` | Use their report-defined valid period and accept the common generate flags. | | `generate tomorrow` | Uses the next local civil day and writes `tomorrow.md` by default. |
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--start`, `--end`, `--hours`, or `--duration`. | | `generate hourly` | Covers the next six hours in the effective report timezone and writes `hourly.md` by default. It does not accept `--date`, `--hours`, or `--duration`. |
| `generate storm` | Requires both `--start TIME` and `--end TIME`. Each time may be `YYYY-MM-DDTHH:MM` in the effective timezone or an RFC3339 timestamp with an explicit offset. | | `run morning` and `run evening` | Run their defined report batches beneath the configured output directory, or the current directory when none is configured. `--out-dir` selects another directory. `--out` is not accepted. |
| `run morning` and `run evening` | Run their defined report batches. `--out-dir` writes extra Markdown copies; `--out` is not accepted. |
`generate` accepts all seven report command names shown above. `run` accepts `generate` accepts the four report command names shown above. `run` accepts
only `morning` and `evening`. Batch membership, workspace artifacts, and only `morning` and `evening`. Batch membership and notification ordering are
notification sequencing are described in the [operations guide](operations.md). described in the [operations guide](operations.md).
## Output, Errors, And Quiet Mode ## Output, Errors, And Quiet Mode
For `generate`, the report's default filename is placed beneath
`output.directory` when configured, otherwise the current directory. `--out
PATH` selects one complete output file instead. A relative path is resolved
from the current directory; an absolute path is used as given. For a batch,
the configured directory has the same role and `--out-dir PATH` selects its
output directory instead. Successful summaries always report the resulting
absolute `outputPath` values. See the [configuration reference](config.md) for
the field's validation and path rules.
Outputs are written atomically. A generation, rendering, write, or cancellation
failure before publication leaves an existing destination unchanged. A
notification failure occurs after publication, so the newly written output
remains available.
Action commands (`generate` and `run`) write a JSON summary to stdout unless 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 `--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, 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 or configuration-load failure, produces no partial JSON summary. When an
fails after it has produced a result, its summary has `"status": "failed"` and action fails after it has produced a result, its summary has `"status": "failed"`
an `error` field. and an `error` field.
`--quiet` is supported by action commands only. It suppresses action summaries `--quiet` is supported by action commands only. It suppresses action summaries
and routine batch status output; it does not suppress command errors. and routine batch status output; it does not suppress command errors.
Inspection commands always write their requested JSON value to stdout and do
not accept `--quiet`.
### Generate Summary ### Generate Summary
A generate summary always identifies the command, report, run, generation A generate summary identifies the command, report, run, generation time, valid
time, valid period, and status: period, prompt version, timezone, and status. Successful output has an absolute
`outputPath`:
```json ```json
{ {
"command": "generate", "command": "generate",
"reportId": "today", "reportId": "today",
"reportName": "Today Report",
"promptId": "weather.today_generated_text", "promptId": "weather.today_generated_text",
"promptVersion": "2.0.0",
"runId": "20260529T120000.000000000Z_today", "runId": "20260529T120000.000000000Z_today",
"status": "succeeded", "status": "succeeded",
"generatedAt": "2026-05-29T12:00:00Z", "timezone": "America/Chicago",
"validPeriod": { "outputPath": "/srv/weather/today.md"
"start": "2026-05-29T00:00:00-05:00",
"end": "2026-05-30T00:00:00-05:00"
}
} }
``` ```
When available, the summary also includes `reportPath`, `metadataPath`, When available, the summary also includes the effective `profileId`,
`dataPackagePath`, and `preflightPath`. Generated-text reports additionally `backendId`, `modelName`, `sourceWarnings`, `validationStatus`, requested
include `generatedTextRawPath`, `generatedTextResultPath`, `llmDebugPath`, and compact Distributor `notification` result. It does not
`generatedTextPath`, and `renderContextPath`. `outputPath` is included only include historical or transient artifact paths such as metadata, prompt input,
when `--out` wrote an extra copy. Distributor notification, when attempted, raw generated text, render context, or notification receipts.
adds `notificationPath` and may add a compact `notification` object.
### Run Summary And Stderr ### Run Summary And Stderr
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`, A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
`total`, `succeeded`, `failed`, and a `reports` array. It may also contain a `total`, `succeeded`, `failed`, and a `reports` array. Each report item includes
top-level `notification` object and `error`. Batch status is `failed` if any its identity, status, effective profile and model details when available,
report or the batch notification fails. source warnings, validation status, and absolute `outputPath` after publication.
The top-level summary may also contain a batch `notification` object and
`error`. Batch status is `failed` if any report or the batch notification fails.
The `total`, `succeeded`, and `failed` counters describe report items only, so
a failed batch notification can leave `failed` at `0` while the top-level
notification and action status are `failed`.
Without `--quiet`, batch status lines use this form: Without `--quiet`, batch status lines use this form:
```text ```text
report=today status=succeeded output="reports/today.md" report=today status=succeeded output="/srv/weather/reports/today.md"
batch=morning total=2 succeeded=2 failed=0 batch=morning total=2 succeeded=2 failed=0
``` ```
@@ -112,12 +121,11 @@ batch=morning total=2 succeeded=2 failed=0
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. | | `--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. | | `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` 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. | | `--out PATH` | every `generate` command | Write the report to this complete file destination instead of the configured or current-directory default. |
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. | | `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics under this absolute path. |
| `--out-dir PATH` | `run morning`, `run evening` | Write batch reports beneath this directory instead of the configured or current-directory default. |
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. | | `--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. | | `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
| `--start TIME`, `--end TIME` | `generate storm` | Required storm-event bounds. |
| `--limit N` | `inspect reports` | Maximum runs to list. Defaults to `20`; `0` means no limit. |
Distributor notification is configured through `notify.distributor`; there are Distributor notification is configured through `notify.distributor`; there are
no Distributor-specific CLI flags. See the [configuration reference](config.md). no Distributor-specific CLI flags. See the [configuration reference](config.md).
@@ -125,33 +133,9 @@ no Distributor-specific CLI flags. See the [configuration reference](config.md).
## Invocation Examples ## Invocation Examples
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate daily --date 2026-05-29
weatherreporter generate today --date 2026-05-29 --out ./today.md weatherreporter generate today --out ./reports/today.md
weatherreporter generate hourly --out ./hourly.md weatherreporter generate hourly --out /srv/weather/hourly.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
weatherreporter run morning --out-dir ./reports weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
``` ```
## Inspection Commands
```sh
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_today
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
```
| 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
`scriptorium`. See the [operations guide](operations.md) for artifact lifecycle
and recovery.

View File

@@ -13,8 +13,8 @@ explicit `--config PATH` must exist. Values are applied in this order:
2. the configuration file, when present; and 2. the configuration file, when present; and
3. the `--units` and `--tz` command-line overrides. 3. the `--units` and `--tz` command-line overrides.
Environment variables do not override configuration fields. Output flags write Environment variables do not override configuration fields. Output flags select
extra report copies for a command and do not change configuration. operator-owned destinations for one command and do not change configuration.
## Maintained Examples ## Maintained Examples
@@ -22,8 +22,12 @@ extra report copies for a command and do not change configuration.
collection and generation configuration. collection and generation configuration.
- [config.yml](../examples/config.yml) is a representative production-oriented - [config.yml](../examples/config.yml) is a representative production-oriented
configuration using synthetic endpoints and no credentials. configuration using synthetic endpoints and no credentials.
- [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml)
is a complete endpoint-only override for the embedded `weather-light`
profile.
Both files are loaded by the configuration test suite. The configuration examples are loaded by the configuration test suite. The
profile example is inspected through the Promptkit adapter test suite.
## Minimal Configuration ## Minimal Configuration
@@ -77,6 +81,30 @@ Missing directories, unreadable files, subdirectories, symlinks, non-regular
files, and invalid names fail configuration loading. Put only secret values in files, and invalid names fail configuration loading. Put only secret values in
this directory, never in the YAML file. this directory, never in the YAML file.
### `output`
`output.directory` selects the ordinary operator-owned publication directory
for both individual reports and batches.
| Field | Default | Rules |
| --- | --- | --- |
| `directory` | empty | An omitted or empty value uses the invocation working directory. A nonempty value must contain at least one non-whitespace character. |
The configured value is preserved while configuration loads: it is not cleaned,
made absolute, inspected, created, or expanded through environment variables or
a home-directory shortcut. At execution, an absolute directory is used as
given; a relative directory resolves from the invocation working directory, not
from the configuration file's location. A missing directory is created when a
report is successfully published. An existing non-directory or an uninspectable
path fails output preflight before prompt inspection, weather collection, or
publication.
For one `generate` command, `--out` is a complete file destination and takes
precedence over `output.directory`. For `run`, `--out-dir` takes precedence.
Those explicit flags do not inspect or rebase beneath the configured directory.
See the [CLI reference](cli.md) for command selection and the [operations
guide](operations.md) for publication and failure handling.
### `notify.distributor` ### `notify.distributor`
Distributor notification is disabled by default. Its fields are: Distributor notification is disabled by default. Its fields are:
@@ -102,10 +130,9 @@ Use `secrets.directory` when a file-backed secret is appropriate.
Single-report bundle templates accept `location_id`, `report_id`, `run_id`, Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`, `artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`, `valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
and `storm_id`. Pipeline and idempotency-key templates may also use Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
`bundle_id`. Dates use `YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
`YYYY-MM-DDTHHMM` in the effective report timezone. `storm_id` is effective report timezone.
`{valid_start_stamp}-{valid_end_stamp}` for Storm Report and empty otherwise.
Batch bundle and pipeline templates accept `location_id`, `batch`, Batch bundle and pipeline templates accept `location_id`, `batch`,
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may `batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
@@ -124,12 +151,9 @@ The default paths are:
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/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` | | `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` | | `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` |
See the [operations guide](operations.md) for notification timing, uploaded See the [operations guide](operations.md) for notification timing, uploaded
artifact selection, and failure handling. output selection, and failure handling.
### `missing_source` ### `missing_source`
@@ -139,30 +163,45 @@ Hourly forecast data is required for generated reports. Supported optional
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`, source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
`weather_story`, and `spc_convective_outlooks`. `weather_story`, and `spc_convective_outlooks`.
### `scriptorium` ### `promptkit`
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.
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 | | Field | Default | Rules |
| --- | --- | --- | | --- | --- | --- |
| `binary` | `scriptorium` | Required executable name or path. | | `profile` | empty | Optional global profile selection for every report in one command. When empty, each exact prompt version selects its declared default. |
| `config_path` | empty | Optional Scriptorium configuration path. | | `profile_file` | empty | Optional external Promptkit profile file. It cannot be combined with `profile_dir`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
| `profile` | empty | Optional Scriptorium profile. | | `profile_dir` | empty | Optional external Promptkit profile directory. It cannot be combined with `profile_file`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
| `timeout` | `2m` | Must be greater than zero. | | `timeout` | `2m` | Must be greater than zero. |
| `extra_args` | empty | Optional extra arguments passed to Scriptorium commands. | | `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` `profile` selects an ID; `profile_file` and `profile_dir` supply definitions.
They are separate decisions. An explicit `profile` applies to every selected
report. Otherwise Hourly selects `weather-light`, while Daily, Today, and
Tomorrow select `weather-balanced` through their exact `2.0.0` prompt
definitions.
| Field | Default | Promptkit resolves a selected profile definition from a test or embedding
| --- | --- | consumer's explicit in-memory profile, then the configured `profile_file` or
| `root` | `workspace` | `profile_dir`, then Weatherreporter's embedded catalog, and finally Promptkit's
| `snapshots_dir` | `snapshots` | built-in catalog. Sources provide complete definitions; fields are never
| `reports_dir` | `reports` | merged. A matching malformed external profile fails rather than using the
| `data_packages_dir` | `data-packages` | embedded definition. The [Promptkit integration guide](integrations/promptkit.md)
| `preflight_dir` | `preflight` | owns the catalog and precedence details.
| `notifications_dir` | `notifications` |
`workspace.root` is required. Each workspace subdirectory must be a relative To replace the default Hourly definition with a local OpenAI-compatible
path that stays within the root. See the [operations guide](operations.md) for endpoint, set `profile_file` to a copy of
the managed workspace layout and lifecycle. [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml).
The example has no credential and should be edited for the local endpoint and
model before use. An alternative profile may use `backend: local`; in that
case `promptkit.local.endpoint` supplies the conventional local backend
endpoint.
### `dayparts` ### `dayparts`
@@ -172,26 +211,13 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
(`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon` (`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon`
(`15:00``17:00`), and `evening` (`17:00``24:00`). (`15:00``17:00`), and `evening` (`17:00``24:00`).
### `recent_change`
| Field | Default |
| --- | --- |
| `temperature_degrees` | `5` |
| `precip_probability_points` | `20` |
| `wind_gust_miles_per_hour` | `10` |
| `precip_timing_shift_minutes` | `120` |
These thresholds control when Recent Changes are included in prompt input for a
prior comparable module snapshot.
### `reports` ### `reports`
`reports` optionally overrides a report's ordered deterministic modules and `reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults. Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`, `three_day`, Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
`weekend`, and `storm`. Configuration also accepts `three_day_outlook`, and underscores are equivalent.
`weekend_outlook`, and `storm_report`; hyphens and underscores are equivalent.
Each report entry can contain: Each report entry can contain:

View File

@@ -5,9 +5,9 @@ Weatherreporter. It provides a concise repository orientation and routes each
kind of change to its canonical documentation. kind of change to its canonical documentation.
Weatherreporter is a Go CLI that collects normalized weather data, derives Weatherreporter is a Go CLI that collects normalized weather data, derives
deterministic report facts and module snapshots, invokes Scriptorium for deterministic report facts and module snapshots, executes Promptkit for
generated text, renders managed Markdown reports, and can upload completed single-report generated text, renders Markdown reports, and can upload completed
reports through Distributor. Start with the [README](../README.md) for product operator-owned outputs through Distributor. Start with the [README](../README.md) for product
context and the [architecture policy](policy/architecture.md) for system context and the [architecture policy](policy/architecture.md) for system
boundaries and invariants. boundaries and invariants.
@@ -21,17 +21,17 @@ boundaries and invariants.
| 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. | | 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. | | 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. | | 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. | | Top-level generation, batch, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, output publication, 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. | | 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. | | 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. | | 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. | | 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. |
| Scriptorium commands, subprocess execution, prompt inputs, or result handling | [Scriptorium integration](integrations/scriptorium.md), [Scriptorium adapter internals](internal/scriptorium-adapter.md), and [prompt-input internals](internal/prompt-input.md) | These separate the external CLI contract, subprocess boundary, 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. | | 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. | | Output destinations, atomic publication, prompt diagnosis, or legacy cleanup | [Operations guide](operations.md) and [App orchestration internals](internal/app-orchestration.md) | Operations owns operator workflows; app internals owns the implementation boundary. |
| 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. | | Distributor bundles, uploads, notification results, 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. | | 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. | | 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 For an existing subsystem, inspect its focused internal document, package-local
@@ -44,13 +44,13 @@ present before introducing a new package or abstraction.
| --- | --- | | --- | --- |
| `cmd/weatherreporter` | Binary entry point. | | `cmd/weatherreporter` | Binary entry point. |
| `internal/cli` | Command parsing, flags, help, output, and command wiring. | | `internal/cli` | Command parsing, flags, help, output, and command wiring. |
| `internal/app` | Generation, batches, collection coordination, notification, and inspection orchestration. | | `internal/app` | Stateless generation, batches, collection coordination, output publication, and notification. |
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. | | `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
| `internal/adapters` | Weather API, Scriptorium, and Distributor boundaries. | | `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. | | `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/report`, `internal/module`, `internal/briefing` | Report registry plus module and briefing contracts. |
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. | | `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. | | `internal/fileutil`, `internal/timeutil` | Atomic output operations, clocks, dates, timezones, and periods. |
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. | | `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
| `examples` | Maintained copyable configuration. | | `examples` | Maintained copyable configuration. |
@@ -68,7 +68,7 @@ implemented subsystem behavior.
5. Run repository-wide validation before considering the work complete. 5. Run repository-wide validation before considering the work complete.
Preserve actionable error context, keep secrets out of logs and fixtures, and Preserve actionable error context, keep secrets out of logs and fixtures, and
avoid validation that requires live Weather API, Scriptorium, or Distributor avoid validation that requires live Weather API, Promptkit providers, or Distributor
services. The architecture and testing policies own the detailed rules. services. The architecture and testing policies own the detailed rules.
## Baseline Validation ## Baseline Validation

View File

@@ -58,8 +58,8 @@ application to record.
Run and idempotency records are in-memory. Completed records expire according Run and idempotency records are in-memory. Completed records expire according
to Distributor's `server.http.retention`, and a Distributor restart removes to Distributor's `server.http.retention`, and a Distributor restart removes
retained status and idempotency state. Status polling decisions and persistence retained status and idempotency state. Status polling decisions are internal
of notification artifacts are internal orchestration behavior; see the orchestration behavior; see the
[Distributor adapter](../../internal/distributor-adapter.md) and [Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md). [application orchestration](../../internal/app-orchestration.md).

View File

@@ -8,15 +8,15 @@ the upload call returns.
## File Mappings ## File Mappings
Every mapping pairs a managed Markdown report source with one bundle-relative Every mapping pairs an operator-owned Markdown output with one bundle-relative
path. A single-report notification maps its one managed report to each rendered path. A single-report notification maps its published output to each rendered
path configured for that report. A batch notification combines mappings for path configured for that report. A batch notification combines mappings for
every included managed report and rejects duplicate bundle paths. every included published output and rejects duplicate bundle paths.
The report source is never an `--out` copy or an arbitrary workspace scan. The The report source is the output selected for that command; the application does
application selects it and renders notification paths; see the [operations guide](../../operations.md) not scan local directories. It renders notification paths after publication;
for the managed-upload rule and the [Distributor adapter](../../internal/distributor-adapter.md) see the [operations guide](../../operations.md) and the
for the adapter boundary. [Distributor adapter](../../internal/distributor-adapter.md) for the boundary.
Bundle paths must be clean, relative, slash-separated paths. They cannot be Bundle paths must be clean, relative, slash-separated paths. They cannot be
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use empty or absolute, contain backslashes, empty segments, `.` or `..`, or use

View File

@@ -16,7 +16,7 @@ For each notification, Weatherreporter calls `UploadFiles` with:
- the rendered pipeline ID; - the rendered pipeline ID;
- the rendered bundle ID as the source manifest ID; - the rendered bundle ID as the source manifest ID;
- the report or batch generation time as `Created`; - the report or batch generation time as `Created`;
- the managed-report-to-bundle-path mappings described in the - the published-output-to-bundle-path mappings described in the
[bundle mapping contract](pkg-bundle.md); and [bundle mapping contract](pkg-bundle.md); and
- a rendered idempotency key. - a rendered idempotency key.
@@ -38,8 +38,8 @@ adapter translates it to its own conflict error without exposing the token.
The adapter then calls `Status` for the accepted run. A terminal `failed` 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 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 terminal status remains attached to the otherwise accepted upload as diagnostic
status information. Polling cadence, final failure handling, redaction, and status information. Polling cadence, final failure handling, and redaction are
notification artifact persistence are internal behavior documented in the internal behavior documented in the
[Distributor adapter](../../internal/distributor-adapter.md) and [Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md). [application orchestration](../../internal/app-orchestration.md).

View File

@@ -0,0 +1,34 @@
# 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 `2.0.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
## Logical Profile Catalog
Prompt definitions select a stable Weatherreporter profile ID. The embedded definitions currently use Promptkit's `openrouter` backend:
| Profile ID | Model | Reasoning effort | Timeout | Service tier | Default reports |
| --- | --- | --- | --- | --- | --- |
| `weather-light` | `deepseek/deepseek-v4-flash` | Provider default | 180 seconds | `flex` | Hourly |
| `weather-balanced` | `~google/gemini-flash-latest` | `high` | 240 seconds | `flex` | Daily, Today, Tomorrow |
| `weather-deep` | `~anthropic/claude-sonnet-latest` | `high` | 240 seconds | `flex` | None |
The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded profiles intentionally omit endpoints, credentials, temperature, `top_p`, and output-token limits.
## Selection And Active Execution
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
1. explicit in-memory profiles used by an embedding consumer or test;
2. the configured `profile_file` or `profile_dir`;
3. Weatherreporter's embedded fallback profiles; and
4. Promptkit's built-in catalog.
A source falls through only when the selected ID is absent. Each source supplies a complete definition, so profile fields are not merged. A malformed matching operator definition is an error and does not fall back.
Profiles that require a direct API key are unsupported; a profile that reports `APIKeyEnv` requires a nonblank value in that environment variable. Active results retain the selected logical profile ID and resolved backend and model. Ordinary errors, summaries, logs, and outputs exclude endpoints, credentials, rendered messages, schemas, request bodies, response bodies, and complete parameter maps.
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
Prompt/profile configuration and the maintained local override example are owned by the [configuration reference](../config.md). Adapter construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).

View File

@@ -1,91 +0,0 @@
# Scriptorium Integration
`weatherreporter` invokes the Scriptorium executable as a subprocess to
preflight prompt input and produce report artifacts. This is the limited CLI
contract Weatherreporter uses, not general Scriptorium documentation.
## Invocation
The configured `scriptorium.binary` is the executable name or path. When it is
empty, the adapter invokes `scriptorium`. Arguments are passed directly to the
process, without a shell.
For every command, arguments occur in this order:
1. The subcommand.
2. `--config <path>` when `scriptorium.config_path` is set.
3. `--profile <profile>` when `scriptorium.profile` is set.
4. The command-specific arguments below.
5. Each configured `scriptorium.extra_args` item.
The adapter uses these exact command shapes:
```text
scriptorium render [--config <path>] [--profile <profile>] \
--prompt <prompt_id> --input data_package=<data_package_path> --format json \
[<extra_arg> ...]
scriptorium run [--config <path>] [--profile <profile>] \
--prompt <prompt_id> --input data_package=<data_package_path> --out <output_path> \
[<extra_arg> ...]
```
`render` is the preflight command. `run` writes either a Markdown report or a
raw generated-text artifact to the supplied `--out` path. The structured
generated-text use of `run` has the same argv as Markdown generation; it does
not add `--format`, `--schema`, `--schema-path`, or `--json-schema` flags.
Prompt configuration selected by `<prompt_id>` controls that output.
## Inputs and Outputs
Weatherreporter always supplies exactly one prompt input:
`--input data_package=<data_package_path>`. The path identifies the YAML data
package produced by the [prompt-input builder](../internal/prompt-input.md).
Its schema and the separate JSON module snapshots are internal artifacts, not
part of this CLI contract.
The application supplies an already-managed output path to every `run` call.
For direct reports it is the Markdown artifact path. For generated-text
reports it is the raw JSON artifact path; subsequent validation and Markdown
rendering are owned by [generated-text processing](../internal/generatedtext.md).
`render` has no output-path argument. Its JSON-formatted stdout remains
captured output: the adapter records it and does not parse it into a separate
CLI result type. Likewise, the adapter records `run` output metadata without
decoding the artifact written at `--out`.
## Execution and Results
`scriptorium.timeout`, when greater than zero, creates a timeout for each
subprocess invocation. Parent-context cancellation and that timeout stop the
command through the process context.
Stdout and stderr are captured independently, each up to 1 MiB. Every returned
result records the complete argv as `command`, the captured `stdout` and
`stderr`, `exitCode`, and `stdoutTruncated` and `stderrTruncated` when a stream
was capped. Results from both forms of `run` also record `outputPath`, the
requested `--out` value.
The [Scriptorium adapter](../internal/scriptorium-adapter.md) owns process
execution and result capture. [Application orchestration](../internal/app-orchestration.md)
owns when preflight output, report artifacts, and generated-text artifacts are
persisted.
## Failure Behavior
Before starting Scriptorium, the adapter requires a prompt ID and data-package
path for every command, plus an output path for `run`. Missing fields fail
without executing a subprocess.
A nonzero process exit returns the captured result and an error that includes
the exit code and stderr. An output file written before such an exit does not
make the request successful. Failures to start the command, context
cancellation, and timeout return an error rather than a successful result.
## Operational Notes
- Extra arguments are argv items; they are not shell-interpreted.
- Prompt input, generated artifacts, stdout, and stderr can contain
operationally sensitive weather data.
- Provide API keys through the Scriptorium environment or its configuration,
not through Weatherreporter CLI arguments.

View File

@@ -1,110 +1,27 @@
# Application Orchestration Internals # Application Orchestration Internals
`internal/app` composes top-level generation, batch, collection-save, and `internal/app` owns stateless report generation, batch execution, atomic output publication, and notification coordination after `internal/cli` has parsed arguments and loaded configuration. The user contract is owned by the [CLI reference](../cli.md) and [operations guide](../operations.md).
inspection workflows after CLI parsing and configuration loading. It owns
workflow ordering, request composition, partial-result handling, and the
application-facing interfaces used for tests.
## Inputs And Outputs ## Single-Report Flow
The package accepts generate, resolved-report, batch, explicit-collection, and `GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
inspection requests. Generation and batch requests may supply collector,
renderer, store, and notifier implementations for tests; production defaults
use the focused packages.
A report result contains the module snapshot, prompt package, available The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run.
Scriptorium results, generated-text artifacts when used, report and metadata
paths, prior snapshot, Recent Changes, and notification information. A batch
result contains aggregate counts, per-report outcomes, and an optional batch
notification. Inspection returns persisted values only.
Exact public command syntax, configuration fields, workspace layout, external Failures return an active partial result with safe identity, profile, warning, validation, debug, and output information when available. After rendering and immediately before publication, the workflow checks for cancellation or deadline expiry. Any failure before publication leaves an existing destination unchanged. A notification failure retains the newly published output.
protocols, and report definitions belong in [the CLI reference](../cli.md),
[the configuration reference](../config.md), [operations](../operations.md),
and their focused integration and internal documents.
## Single-Report Workflow ## Batches
`GenerateDetailed` first collects weather data, then resolves the requested `RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating prompt and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
report using the configured registry and current time, and finally calls
`GenerateReport` with that explicit collection. It returns no result when
collection or resolution fails.
`GenerateReport` requires a non-nil normalized bundle and then performs this Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
ordered work:
1. Select a state store, determine artifact destinations, and locate a prior ## Boundaries And Verification
compatible snapshot.
2. Build report facts and deterministic module snapshots, then save the module
snapshot and calculate Recent Changes.
3. Build and save the prompt data package, run Scriptorium render preflight,
save any preflight result, and save initial metadata.
4. Produce managed Markdown according to the report generation mode.
5. Optionally make an output copy, save final metadata, optionally notify
Distributor from the managed report path, and save metadata again when a
notification path is produced.
Direct-Markdown reports prepare the managed report and invoke the Scriptorium The package does not parse flags, load YAML, implement transport, construct provider SDKs, or define report-period policy. Prompt, profile, weather, and Distributor implementations remain behind project-owned contracts.
run boundary. Generated-text-template reports look up their catalog definition,
run structured Scriptorium output to the raw artifact, preserve any structured
run result, validate and save generated text, build and save a render context,
then render the embedded Markdown template. Schema, template, and subprocess
details remain in their [generated-text](generatedtext.md),
[report-template](reporttemplate.md), and [Scriptorium adapter](scriptorium-adapter.md)
owners.
If preflight returns a result with an error, the result and initial metadata are Focused checks:
saved before the error returns. If report generation fails after a managed path
is prepared, metadata still records that path; output copies and notification
are skipped. Generated-text failures preserve the latest artifact reached
before failure when it was saved.
## Batch And Inspection Workflows ```sh
go test ./internal/app ./internal/collect
`RunBatchDetailed` collects once, asks the report registry to plan the batch ```
from that collection, and invokes `GenerateReport` independently for every
planned report using the same collection and state store. Per-report
notification is suppressed. A failed report is recorded and does not prevent
later planned reports from running.
After report generation, the batch notifier is considered once. It is omitted
when Distributor or batch notification is disabled, skipped when any report
failed, and otherwise receives one multi-file request. A batch notification
failure increments the aggregate failure count but does not rewrite successful
report items. Notification identities, path mappings, polling, and redaction
are owned by the [Distributor adapter](distributor-adapter.md).
Inspection methods create a state store and load existing report records,
metadata, module snapshots, prompt packages, prior snapshots, or source
provenance. They neither collect data nor invoke Scriptorium or Distributor.
## Boundaries And Failure Propagation
The app layer does not parse flags, load configuration files, implement Weather
API transport, construct Scriptorium argv, or define report registry policy. It
coordinates the relevant collaborators and preserves their error context.
- Collection failure stops a single report or batch before resolution or
planning completes.
- State, fact, module, prompt-input, or preflight failures stop that report
before report generation.
- A terminal Distributor failure is returned with the saved notification
information when available.
- Batch failures are represented per report and through aggregate batch status.
- Persisted artifact paths are carried in results so callers can inspect work
completed before a later failure.
## Tests And Invariants
Focused tests are in `internal/app/app_test.go` and
`internal/app/batch_plan_test.go`, with collection coverage in
`internal/collect/collect_test.go`.
- Production workflows collect through `internal/collect`.
- A report uses one explicit normalized collection throughout its generation.
- Render preflight precedes report generation.
- Recent Changes compare structured module snapshots.
- Generated-text reports render from a validated typed context, never directly
from a raw prompt package.
- Only managed Markdown reports are notification sources; output copies are
never uploaded.

View File

@@ -4,7 +4,7 @@
collected facts, and derived facts. It owns the module registry, including collected facts, and derived facts. It owns the module registry, including
module support, fact requirements, option types, missing-data policy, builders, module support, fact requirements, option types, missing-data policy, builders,
and prompt-export hooks. It does not collect data, derive periods, write a and prompt-export hooks. It does not collect data, derive periods, write a
snapshot, construct YAML, invoke Scriptorium, or render a report. snapshot, construct YAML, invoke Promptkit, or render a report.
## Registry and construction ## Registry and construction
@@ -66,4 +66,4 @@ go test ./internal/briefing
``` ```
Builders emit structured facts, never report prose. The app collects their Builders emit structured facts, never report prose. The app collects their
outputs into a module snapshot, and state persists that snapshot. outputs into an in-memory module snapshot for prompt input and rendering.

View File

@@ -1,61 +0,0 @@
# Changes Internals
`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).
## Comparison inputs and output
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.
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.
## Strategies
| 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 |
| `CompareThreeDay` | `derived_daypart_summaries` | Per-day temperatures, precipitation probability and timing, peak gust, indicators, and added or removed outlook days |
| `CompareWeekend` | `derived_daypart_summaries` | The three-day values with weekend-prefixed change types |
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. The multi-day comparators build their day map from daypart
summaries. A missing or added day becomes a dedicated change rather than a
comparison against invented data.
The application selects a comparator only after state lookup establishes a
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator;
Three-day and Weekend use their named comparators. Other report types, such as
Storm, produce no Recent Changes list.
## Missing data and failures
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.
The package has no filesystem, transport, CLI, renderer, or persistence
behavior. It does not decide report compatibility or retain snapshots.
## Verification and invariants
Focused tests cover the daily, three-day, and weekend strategies, threshold
boundaries, indicator and alert changes, and missing required stanzas:
```sh
go test ./internal/changes
```
Recent Changes always compare structured snapshot values, never report prose.

View File

@@ -1,65 +1,15 @@
# CLI Internals # CLI Internals
`internal/cli` turns process arguments into application requests and translates `internal/cli` parses terminal arguments, loads configuration, constructs app requests, and translates app results to bounded JSON summaries. The public contract belongs in the [CLI reference](../cli.md).
application results into terminal output. The user-facing command, flag, and
output contract belongs in the [CLI reference](../cli.md).
## Responsibilities The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
`Runner.Run` dispatches the top-level action or inspection request. For actions, For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
the package parses command-specific and common flags, loads configuration with
CLI overrides, obtains the current time, and constructs either an
`app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and
batch execution to `internal/app`.
For inspection, it loads configuration, builds the appropriate app inspection The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned.
request, and writes the returned value. Inspection is read-only; the inspected
artifact types and user invocation remain owned by the [CLI reference](../cli.md)
and [operations guide](../operations.md).
## Result Translation CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
Action results become CLI-safe JSON summaries in `result.go`. Generate summaries ```sh
carry report identity, status, relevant artifact paths, and notification go test ./internal/cli
summary data. Batch summaries carry aggregate counts, per-report outcomes, and ```
the optional batch notification result. The translation deliberately excludes
full module snapshots, prompt packages, raw generated text, Scriptorium output,
and complete Distributor payloads.
When an action returns both a result and an error, the CLI writes the failed
summary before returning that error. Parse, configuration-load, and other
failures that produce no application result return without a summary.
`writeActionResult` writes action status information to stderr first, then JSON
to stdout. Batch execution supplies the status writer; single-report generation
does not emit routine stderr output. Quiet action requests suppress both normal
streams but still return errors. Inspection writes its JSON value to stdout and
does not accept quiet mode because stdout is the inspection result.
## Boundaries
The package owns argument parsing, request adaptation, help text, and terminal
presentation. It does not implement report selection, collection, state
persistence, external transport, subprocess execution, or notification policy.
Those concerns remain in [application orchestration](app-orchestration.md) and
their focused owners.
## Failure Behavior
- Invalid command names, flags, dates, and configuration fail before an app
request is executed.
- Application errors retain their application context; output helpers do not
hide or replace them.
- JSON-encoding errors are returned directly.
- A failed batch summary causes the CLI to return an aggregate batch error even
when the detailed batch call has already returned its result.
## Tests And Invariants
Focused tests are in `internal/cli/root_test.go`, `internal/cli/output_test.go`,
and `internal/cli/result_test.go`.
- CLI summaries are stable, bounded views of app results.
- Routine batch status lines precede the batch JSON summary.
- A quiet action produces no successful or failure summary output.
- Inspection never invokes action-output helpers.

View File

@@ -13,7 +13,7 @@ calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
The package wraps adapter construction failures as weather-collection setup The package wraps adapter construction failures as weather-collection setup
errors and fetch failures as bundle-collection errors. It does not retry, errors and fetch failures as bundle-collection errors. It does not retry,
persist, select reports, derive facts, build modules, invoke Scriptorium, or persist, select reports, derive facts, build modules, invoke Promptkit, or
notify Distributor. notify Distributor.
## Application Composition ## Application Composition

View File

@@ -47,7 +47,7 @@ status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
and remote error details. and remote error details.
Status lookup or polling errors are preserved in `UploadResult.StatusError` so Status lookup or polling errors are preserved in `UploadResult.StatusError` so
the caller can record an accepted-but-unconfirmed delivery. A terminal failed the caller can report an accepted-but-unconfirmed delivery. A terminal failed
run returns that result and an error. Upload failures return no result. Upstream run returns that result and an error. Upload failures return no result. Upstream
idempotency conflicts become the local `IdempotencyConflictError`, which adds idempotency conflicts become the local `IdempotencyConflictError`, which adds
endpoint, pipeline, bundle, idempotency, and file-path context while redacting endpoint, pipeline, bundle, idempotency, and file-path context while redacting

View File

@@ -33,10 +33,8 @@ Report identity controls the summary shape:
| --- | --- | | --- | --- |
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary | | Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts | | Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
| Three-day, Weekend | One clipped daily summary for each overlapping local day |
| Storm | One summary for the explicit report window |
`DaypartSummaries` is collected from the resulting daily or storm summaries. `DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md). [forecast derivation](forecast-derivation.md).
@@ -55,13 +53,12 @@ not access the CLI, filesystem, subprocesses, or network.
## Verification and invariants ## Verification and invariants
Focused tests cover collected-fact separation, report-period selection, Focused tests cover collected-fact separation, report-period selection,
hourly and storm behavior, daily and partial-day summaries, and convective hourly behavior, daily summaries, and convective outlook selection:
outlook selection:
```sh ```sh
go test ./internal/facts go test ./internal/facts
``` ```
Facts are derived once for a resolved report from already collected data. Facts are derived once for a resolved report from already collected data.
They remain reusable structured values: prompt wording, state persistence, They remain reusable structured values for prompt input and template
prior-report comparison, and template presentation are owned elsewhere. presentation, which are owned elsewhere.

View File

@@ -46,9 +46,7 @@ UTC when these APIs are called directly. Optional narrative, discussion, and
alerts remain absent when their normalized products are absent. alerts remain absent when their normalized products are absent.
Forecast thresholds used for brief indicators and precipitation timing are Forecast thresholds used for brief indicators and precipitation timing are
implementation rules. User-configurable Recent Changes thresholds are applied implementation rules.
by [changes internals](changes.md), whose defaults are documented in
[configuration](../config.md).
## Verification and invariants ## Verification and invariants

View File

@@ -8,17 +8,18 @@ maintainer-facing context fields belong to [report templates](../templates.md).
## Catalog and validation ## Catalog and validation
Only the Daily, Today, Tomorrow, and Hourly report definitions use the The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated-text-template mode. `LookupDefinition` rejects a direct-Markdown generated text. `LookupDefinition` rejects unknown schema or template IDs and
definition, unknown schema or template IDs, and unsupported schema/template unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
pairs before the run begins. A handler validates raw JSON, returns a typed value and canonical normalized JSON, loads its canonical schema through
value and canonical normalized JSON, loads its schema, builds a render context, `internal/promptassets`, builds a render context, and renders through
and renders through `internal/reporttemplate`. `internal/reporttemplate`.
Daily, Today, and Tomorrow use a day-style value with required trimmed summary 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 one or more nonblank discussion paragraphs. Hourly requires trimmed summary
and a single trimmed discussion string. Each form permits optional trimmed and a single trimmed discussion string. Every form also requires the
precipitation-timing and confidence prose. Typed decoding rejects unknown JSON `precipitation_timing` field; an empty string means there is no supported timing
prose to render. Typed decoding rejects missing required fields and unknown JSON
fields; no general-purpose JSON Schema engine is used at runtime. fields; no general-purpose JSON Schema engine is used at runtime.
## Render contexts ## Render contexts
@@ -33,8 +34,8 @@ template iteration rather than maps.
Optional source stanzas become nil or fallback context fields. Missing required Optional source stanzas become nil or fallback context fields. Missing required
stanzas, type-decoding failures, invalid metadata, or a generated-text type stanzas, type-decoding failures, invalid metadata, or a generated-text type
that does not match the chosen handler fail before template execution. Prompt that does not match the chosen handler fail before template execution. Prompt
packages, raw Scriptorium output, state persistence, and template asset lookup packages, raw Promptkit output handling, and template asset lookup remain
remain outside this package. outside this package.
## Verification and invariants ## Verification and invariants
@@ -47,5 +48,5 @@ go test ./internal/generatedtext
``` ```
Generated text supplies prose slots only; deterministic weather facts remain in Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. Every generated-text definition must resolve to exactly module and fact values. Every report definition must resolve to exactly one
one supported catalog pair. supported catalog pair.

View File

@@ -1,7 +1,7 @@
# Module Contract Internals # Module Contract Internals
`internal/module` defines the stable envelope between report composition, `internal/module` defines the stable envelope between report composition,
module builders, snapshots, comparisons, templates, and prompt packages. It module builders, in-memory snapshots, templates, and prompt packages. It
does not define a report, execute a builder, or choose prompt-export policy; does not define a report, execute a builder, or choose prompt-export policy;
those responsibilities belong to [report registry](report-registry.md) and those responsibilities belong to [report registry](report-registry.md) and
[briefing](briefing.md). [briefing](briefing.md).
@@ -11,10 +11,10 @@ those responsibilities belong to [report registry](report-registry.md) and
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
`PromptValue`. `DataPackageValue` returns the prompt value when present and `PromptValue`. `DataPackageValue` returns the prompt value when present and
otherwise the rich value. This permits custom prompt exports without shrinking otherwise the rich value. This permits custom prompt exports without shrinking
the template and inspection value. the template value.
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and `NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
validates it. Snapshot JSON persists IDs, stanza names, and rich values only; validates it. Its JSON representation contains IDs, stanza names, and rich values only;
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich `PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
stanza into a caller-supplied type, reporting a missing stanza separately from stanza into a caller-supplied type, reporting a missing stanza separately from
a decoding error. a decoding error.
@@ -39,8 +39,6 @@ The registry declares these ordered default compositions:
| 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 | | 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 | | 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 | | Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
| Three-day and Weekend | metadata, current conditions, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows |
| Storm | metadata, current conditions, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story |
The only non-empty default option is the AFD section selection. It accepts a The only non-empty default option is the AFD section selection. It accepts a
`sections` list; omitted or empty selects all available sections. Report `sections` list; omitted or empty selects all available sections. Report
@@ -49,7 +47,7 @@ are validated by the briefing registry.
## Rich and prompt-facing values ## Rich and prompt-facing values
Rich values remain available to snapshots, comparisons, and render contexts. Rich values remain available to module snapshots and render contexts.
Briefing attaches custom prompt exports only for current conditions, hourly Briefing attaches custom prompt exports only for current conditions, hourly
forecast, and derived daypart summaries; all other current builders use forecast, and derived daypart summaries; all other current builders use
pass-through values. The prompt package owns how exported stanzas are grouped pass-through values. The prompt package owns how exported stanzas are grouped

View File

@@ -1,29 +1,16 @@
# Prompt Input Internals # Prompt Input Internals
`internal/promptinput` converts report metadata, an ordered module snapshot, `internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends.
Recent Changes, and source warnings into the YAML `data_package` consumed by
Scriptorium. It owns this package's schema, grouping, serialization, loading,
and validation—not weather collection, module construction, path choice, or
subprocess execution.
## Package construction ## Package Construction
`Build` produces `weatherreporter.data_package.v3`. It copies the run ID; `Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and source warnings. Prompt input contains no historical comparison section.
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.
Briefing starts as a flat snapshot order and stanza-value map. `Build` uses Briefing is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated 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).
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).
## YAML ordering and grouping ## YAML Ordering And Validation
Serialization keeps `metadata` directly under `briefing`. Every other known Serialization keeps `metadata` directly under `briefing`. Every other known stanza is placed in one category and emitted in category order while preserving its original module order:
stanza is placed in exactly one category, emitted in category order and in its
original snapshot order within that category:
| Category | Current stanzas | | Category | Current stanzas |
| --- | --- | | --- | --- |
@@ -32,30 +19,10 @@ original snapshot order within that category:
| `narrative_products` | narrative forecast, discussions, and weather story | | `narrative_products` | narrative forecast, discussions, and weather story |
| `raw_data` | current conditions and hourly forecast | | `raw_data` | current conditions and hourly forecast |
This YAML presentation does not alter the flat snapshot model. `LoadYAML` `LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit.
accepts the same category layout and reconstructs flat `Order` and `Values`,
rejecting misplaced, duplicate, unknown, or uncategorized stanzas.
## Validation and persistence Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves:
`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.
`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 ```sh
go test ./internal/promptinput go test ./internal/promptinput
``` ```
The package is narrower than a template render context and never infers changes
from report prose.

View File

@@ -0,0 +1,17 @@
# 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 supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, the embedded fallback catalog, and its built-in catalog; the adapter does not parse profile YAML, merge sources, or probe endpoints.
The adapter exposes exact prompt and profile validation plus prepared execution. It maps safe prompt identity, logical profile, effective backend/model, preparation, execution, validation, and optional debug values into `promptexec`. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
The application uses the preparation callback to record active safe provenance in memory and optionally writes content-rich diagnostics only through an explicit debug writer. The adapter returns raw output for application validation and rendering. It does not retain application 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,76 +1,30 @@
# Report Registry Internals # Report Registry Internals
`internal/report` owns the registry of report identities and the data declared `internal/report` owns report identities, valid-period resolution, exact prompt identity and version, output names, default module composition, and Distributor path declarations. Public command syntax belongs in the [CLI reference](../cli.md); configuration aliases and overrides belong in the [configuration reference](../config.md).
for each one: resolution, generation mode, prompt identity, 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).
## Definitions and resolution ## Definitions And Resolution
Each `Definition` declares a stable ID and display name, prompt ID, generation Each `Definition` declares a stable ID and display name, prompt ID and version, template and generated-text schema IDs, valid-period resolver, default output name, Distributor path templates, module list, and fixed batch eligibility. `Resolved` combines a definition with one valid period and run identity.
mode, optional 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.
| Report ID | Mode | Period policy | Comparison | Registry batch flag | Output copy | | Report ID | Prompt version | Default profile | Period policy | Fixed batch flag | Default output |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `daily` | Generated text + template | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` | | `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
| `today` | Generated text + template | Selected or current local civil day | Same valid date | Morning | `today.md` | | `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
| `tomorrow` | Generated text + template | Next local civil day | Same valid date | Evening | `tomorrow.md` | | `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
| `hourly` | Generated text + template | Rolling six-hour interval | Rolling window | — | `hourly.md` | | `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
| `three_day` | Scriptorium Markdown | Generation time through the third following local midnight | Same valid date | Morning | `three-day.md` |
| `weekend` | Scriptorium Markdown | Upcoming weekend window | Weekend window | Morning | `weekend.md` |
| `storm` | Scriptorium Markdown | Caller-supplied event window | Explicit window | — | `storm.md` |
The four generated-text reports pair their report ID with matching template and Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
schema IDs. The three direct-Markdown reports leave both IDs empty. Exact
template fields and schema assets belong to [report templates](../templates.md)
and [generated-text internals](generatedtext.md).
All valid periods are half-open. Storm accepts local `YYYY-MM-DDTHH:MM` values ## Collaborators And Boundaries
in the effective report timezone or offset-bearing RFC3339 values; its end
must follow its start. Resolving Weekend directly on Sunday is rejected.
## Registry collaborators `DefaultRegistry`, `Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating report identity rules. Registry overrides clone a recognized definition and replace its module list. `DistributorPathTemplates` are consumed by app orchestration; their rendered external bundle-path contract is documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
`DefaultRegistry` is the only source of the seven report definitions. `morning` and `evening` are registry-owned batch names. Fixed flags declare Today and Tomorrow eligibility; app orchestration determines data-dependent Daily membership and the actual batch plan.
`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.
The definition's `DistributorPathTemplates` are internal declarations consumed The registry never collects weather data, parses CLI flags, writes output, executes Promptkit, or delivers a report.
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.
`morning` and `evening` are registry-owned batch names. Registry flags declare Focused tests cover definition completeness, command and alias lookup, period resolution, run IDs, output names, composition defaults, and override validation:
fixed report eligibility; app orchestration determines data-dependent Daily
membership and produces the actual batch plan.
## Module composition and failures
Each definition supplies an ordered `[]module.ConfigItem`; the complete
report-to-module mapping is maintained in [module internals](module.md).
`ArtifactGroup`, `BatchOutputName`, `Generated`, and comparison compatibility
are likewise consumed by state and orchestration rather than recomputed there.
Unknown report IDs or batch names, an invalid weekend resolution, and invalid
storm windows return errors. The registry never collects weather data, builds
modules, parses CLI flags, writes state, executes Scriptorium, or delivers a
report.
## Verification and invariants
Focused tests cover definition completeness, command and alias lookup, period
resolution, run IDs, path declarations, composition defaults, and override
validation:
```sh ```sh
go test ./internal/report go test ./internal/report
``` ```
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,22 +1,21 @@
# Report Template Internals # Report Template Internals
`internal/reporttemplate` embeds and renders the repository's native Markdown `internal/reporttemplate` embeds and renders the repository's native Markdown
templates and exposes their companion generated-text schemas. The current asset templates. The current template IDs are `daily`, `today`, `tomorrow`, and
IDs are `daily`, `today`, `tomorrow`, and `hourly`. The template files, partials, `hourly`. The template files, partials, and complete render-context field
and complete render-context field reference are maintained in reference are maintained in
[report templates](../templates.md). [report templates](../templates.md).
## Assets and lookup ## Assets and lookup
The package embeds top-level templates, shared partials, and JSON schemas from The package embeds top-level templates and shared partials. `Template` returns
its asset directories. `Template` and `Schema` return the requested embedded the requested embedded template and fails with the requested ID when it is
asset and fail with the requested ID when it is unknown or unreadable. unknown or unreadable.
Generated-text catalog handlers obtain schema bytes and template source through Generated-text schemas and Promptkit definitions are owned by
these APIs. Prompt source files are repository assets for prompt registration; `internal/promptassets`; report-template owns Markdown source only. Report
they are not reporttemplate lookup assets. Report definitions select IDs, while definitions select IDs, while [generated-text internals](generatedtext.md)
[generated-text internals](generatedtext.md) verifies the supported verifies the supported schema/template pairing.
schema/template pairing.
## Rendering ## Rendering
@@ -36,16 +35,17 @@ validation.
This package does not collect weather data, build modules, validate generated This package does not collect weather data, build modules, validate generated
text, construct contexts, resolve report definitions, write state, execute text, construct contexts, resolve report definitions, write state, execute
Scriptorium, or upload reports. It produces Markdown bytes for application Promptkit, or upload reports. It produces Markdown bytes for application
orchestration to persist. orchestration to persist.
Focused tests cover asset lookup, schema availability, rendering, partial Focused tests cover template lookup, rendering, partial
behavior, missing keys, and malformed context: behavior, missing keys, and malformed context:
```sh ```sh
go test ./internal/reporttemplate go test ./internal/reporttemplate
``` ```
Embedded assets stay as separate files, shared fragments stay under the partial Embedded templates stay as separate files and shared fragments stay under the
directory, and generated-text schemas describe prose slots rather than partial directory. Generated-text schemas are embedded separately by
deterministic weather facts. `internal/promptassets` and describe prose slots rather than deterministic
weather facts.

View File

@@ -1,51 +0,0 @@
# Scriptorium Adapter Internals
`internal/adapters/scriptorium` translates Weatherreporter render requests to
Scriptorium process arguments and translates process results back to local
types. The external CLI and output contract belongs to the
[Scriptorium integration guide](../integrations/scriptorium.md); prompts,
template inputs, and report ownership remain outside this adapter.
## Request-to-command translation
`Runner` accepts a binary, config path, profile, timeout, extra arguments, and
an injectable command executor. Its defaults are the `scriptorium` binary and
the real `ExecRunner`. Optional configuration flags are placed before the
operation-specific arguments, and extra arguments are appended last.
| Local operation | Required values | Translated arguments |
| --- | --- | --- |
| `Render` | prompt ID, data-package path | `render [--config …] [--profile …] --prompt <id> --input data_package=<path> --format json [extra …]` |
| `Run` | prompt ID, data-package path, output path | `run [--config …] [--profile …] --prompt <id> --input data_package=<path> --out <path> [extra …]` |
| `StructuredRun` | prompt ID, data-package path, output path | Same translation as `Run` |
Blank required values fail before a command starts. The adapter does not add
schema flags or interpret a prompt's payload; it only gives Scriptorium the
named `data_package` input.
## Command execution and result translation
`ExecRunner` uses `exec.CommandContext`, never a shell. A positive configured
timeout creates a child context. Standard output and standard error are
captured independently, each with a 1 MiB limit, and the executed command is
retained for diagnostics.
`RenderResult`, `RunResult`, and `StructuredRunResult` expose the command,
captured output, truncation markers, and exit code. Run results also retain the
requested output path. Exit status zero is successful. A nonzero process exit
returns its result and an error, while a start failure, cancellation, or
deadline failure returns no result and the execution error.
The adapter does not parse rendered JSON, validate a generated report, write
state, or upload a report. Those responsibilities sit with
[application orchestration](app-orchestration.md), [state internals](state.md), and the
relevant delivery adapter.
## Verification
Focused tests cover argument order, validation, bounded capture, timeout and
cancellation handling, and exit-status translation:
```sh
go test ./internal/adapters/scriptorium
```

View File

@@ -1,91 +0,0 @@
# State Internals
The `internal/state` package owns filesystem-backed run state: safe path
derivation, metadata persistence, prior-report lookup, and read-only report
inspection. It does not decide which reports to generate or deliver. For the
operator-facing layout and retention procedures, see the
[operations guide](../operations.md).
## Store construction and artifact paths
`NewFilesystemStore` requires a workspace root and rejects absolute or
escaping values for every configured state directory. `Paths` then validates a
run ID and artifact group before deriving all paths from the report's valid
start date (`YYYY-MM-DD`). This keeps a run's artifacts together while making
the paths safe to use below the configured workspace.
| Artifact | Derived 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` |
| Render preflight | `preflight/<group>/<date>/render.<run-id>.json` |
| Notification record | `notifications/<group>/<date>/distributor.<run-id>.json` |
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
| Generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
| Generated-text source and result | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` and `generated_text_result.<run-id>.json` |
| Generated-text render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
The configured notification root separates notification artifacts from report
artifacts; single-report notification paths use the report's valid date. Report
producers create parent directories as needed and write the report body; state
is responsible for the surrounding paths and saved run artifacts.
Batch Distributor notifications are derived separately as
`notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
Their date is calculated from the batch start in its configured location, and
the batch identity and run ID receive the same path-segment validation as
single-report artifact identifiers.
## Metadata and durable writes
`Metadata` is the durable inventory for a run. It records its schema version,
run identity, generated and valid timestamps, artifact group and mode, source
content and provenance, and the module snapshot, data-package, preflight,
report, generated-artifact, and notification locations when present.
`BuildMetadataFromBriefingMetadata` establishes the common fields; the
application adds locations as artifacts are produced. `SaveMetadata` requires
the run ID and the module snapshot, data-package, preflight, and metadata
paths. The package also saves module snapshots, data packages, preflight
records, generated-text artifacts, render contexts, and notifications. JSON
writes use `fileutil.WriteJSONAtomic`, so readers do not observe a partially
written state file.
The data package itself follows the shared
[prompt-input contract](prompt-input.md). Report text, templates, and external
delivery payloads remain owned by their respective packages and integration
references.
## Prior reports and inspection
`FindPriorSnapshot` searches metadata rather than guessing from filenames. It
only considers an earlier compatible report in the same artifact group and
supports the comparison strategies defined by the report request:
- `same_valid_date` finds an earlier generated report for the same valid day.
- `weekend_window` finds a prior comparable weekend window.
The newest eligible metadata record wins; the current run is excluded.
Unreadable or malformed candidate metadata is ignored so a damaged historical
record does not block a new run.
`ListReports` walks saved metadata, returns results ordered newest-first by
generation time, and treats a missing snapshots directory as an empty history.
`LoadMetadataByRunID` builds on that inspection path. These APIs are read-only;
repairing or pruning stored state is an operational concern.
## Boundaries and verification
The package rejects unsafe path components and incomplete metadata before
writing. Callers must provide a valid report request, artifact group, and
store configuration. Its focused tests cover path derivation, atomic
persistence, metadata validation, comparison eligibility, and report listing:
```sh
go test ./internal/state
```
See [application orchestration](app-orchestration.md) for the order in which
these artifacts are created and [report templates](../templates.md) for the
user-facing report contract.

View File

@@ -1,7 +1,7 @@
# Weather Data Internals # Weather Data Internals
`internal/weatherdata` owns the normalized, wire-independent weather bundle `internal/weatherdata` owns the normalized, wire-independent weather bundle
that passes from collection through rendering and persistence. The Weather API that passes from collection through rendering. The Weather API
adapter translates provider responses into these types; its request, response, adapter translates provider responses into these types; its request, response,
and availability contract is documented in the and availability contract is documented in the
[Weather API integration guide](../integrations/weatherapi.md). [Weather API integration guide](../integrations/weatherapi.md).
@@ -54,8 +54,7 @@ local provenance and whole-run consumers see it. A policy that treats a missing
source as an error returns no partial bundle. source as an error returns no partial bundle.
Warnings describe data completeness, not rendering or delivery failures. Warnings describe data completeness, not rendering or delivery failures.
Those failures are recorded by the application and state layers; see Those failures are reported by [application orchestration](app-orchestration.md).
[application orchestration](app-orchestration.md) and [state internals](state.md).
## Boundaries and verification ## Boundaries and verification

View File

@@ -1,156 +1,136 @@
# Weatherreporter Operations # Weatherreporter Operations
This guide covers normal operation, managed workspace state, inspection, This guide covers normal output handling, Distributor notification, secure
recovery, and operational caveats. See the [CLI reference](cli.md) for complete prompt diagnostics, and cleanup of legacy application state. See the [CLI
command syntax and the [configuration reference](config.md) for fields, reference](cli.md) for command syntax and the [configuration reference](config.md)
defaults, and notification templates. For symptom-based diagnosis, see for fields, defaults, and notification templates.
[Troubleshooting](troubleshooting.md).
## Normal Operation ## Normal Operation
After configuring a Weather API endpoint, generate one report: After configuring a Weather API endpoint, generate one report:
```sh ```sh
weatherreporter generate today --out ./today.md weatherreporter generate today
``` ```
A generation collects weather data, resolves the report period, builds and With no configured output directory, the command writes `today.md` in the
persists the module snapshot and prompt data package, runs Scriptorium current directory. Set `output.directory` to use one ordinary publication
preflight, then produces the managed Markdown report. Daily, Today, Tomorrow, directory for reports, or choose a one-command operator-owned file with
and Hourly reports additionally persist generated-text artifacts, validate the `--out`; a relative path is resolved from the current directory and an absolute
structured generated text, and render Markdown from the validated text and path is used directly. The explicit flag takes precedence over the configured
deterministic values. directory. Weatherreporter renders in memory and atomically replaces the
selected destination only after generation and rendering succeed. It does not
create a default workspace, metadata, receipts, or intermediate output files.
The managed report and its final metadata are saved before single-report A missing configured directory is created only as part of successful report
Distributor notification is attempted. `--out` writes an extra operator copy; publication. If its existing path is not a directory or cannot be inspected,
it never changes the managed report or upload source. A successful generate the command stops before prompt inspection or weather collection, leaving any
command prints its summary to stdout unless `--quiet` is used. existing report unchanged. See the [configuration reference](config.md) for the
field definition and validation rules.
Run a scheduled batch with the same configured collection: Before a destination is published, provider, validation, rendering, write, and
cancellation failures leave an existing report unchanged. A notification
failure happens after publication, so retain and use the completed Markdown
file while resolving the delivery error. The JSON result identifies the
absolute output path and active profile, backend, model, warnings, validation,
debug, and notification information; see the [CLI reference](cli.md) for its
exact fields.
## Batch Outputs And Distributor Notification
Run a scheduled batch with an explicit output directory when appropriate:
```sh ```sh
weatherreporter run morning --out-dir ./reports weatherreporter run morning --out-dir ./reports
``` ```
Each batch collects once before it plans reports. Morning runs Today, Tomorrow, Without `--out-dir`, batch reports are written beneath `output.directory` when
and every eligible dated Daily Report; evening runs Tomorrow and the same configured, otherwise the current directory. The explicit directory applies
eligible Daily Reports. Eligible Daily dates begin after tomorrow and require only to that command and takes precedence over the configured fallback.
complete hourly coverage for their entire local civil day. A batch continues Morning runs Today, Tomorrow, and every eligible dated Daily Report; evening
after an individual report fails and returns an aggregate failure when any runs Tomorrow and the same eligible Daily Reports. Eligible Daily dates begin
report or batch notification fails. after tomorrow and require complete hourly coverage for their local civil day.
A batch collects once, determines the complete report set, and validates every
final output destination before executing its first report prompt. A destination
collision, such as a directory named `tomorrow.md`, stops the batch before any
report output is created or replaced. After successful validation, each selected
report processes independently and successful outputs remain available if
another report fails.
`--out-dir` writes extra copies such as `today.md`, `tomorrow.md`, and When `notify.distributor.enabled` and batch notification are enabled,
`daily-YYYY-MM-DD.md`. These copies are never upload sources. Batch report Weatherreporter sends one Distributor upload only after every selected output
copies and notification behavior are summarized in the CLI result; use the exists. If an item fails, the batch notification is skipped and successful
[CLI reference](cli.md) for its exact JSON and stderr contract. files remain at their selected destinations. A batch notification failure also
leaves all successfully published report files in place. Distributor source
files are those operator-owned Markdown outputs; rendered bundle paths and
delivery status appear in the result, not in a local notification receipt.
Report counters count report items only. A batch notification failure therefore
returns a failed batch status even when all report counters show success; the
top-level notification result contains the delivery diagnostic.
## Managed Workspace For a single report, Distributor notification follows the atomic output write.
See the [configuration reference](config.md) for pipeline, bundle,
idempotency-key, and per-report path templates.
The default workspace root is `workspace`. Artifact paths use the report ## Local Prompt Profile Override
definition's artifact group, the valid-period start date in the effective
timezone, and the RunID:
```text Hourly normally selects the embedded `weather-light` profile. To use a local
workspace/ OpenAI-compatible model without changing prompts or application code, copy
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml),
set its `endpoint` and `model` for the local server, and configure the copy as
`promptkit.profile_file`. The profile file's `weather-light` definition
completely replaces the embedded definition; it does not affect a report that
selects another profile ID.
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json Prompt and profile validation occurs before weather collection. A malformed
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json profile file, missing required credential, or unsupported selected backend
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json stops the command before collection. A reachable profile can still fail later
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json if its local model endpoint is unavailable; Weatherreporter does not switch to
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json a remote profile.
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml ## Optional Prompt Debug Capture
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
```
The generated-text and render-context artifacts are written only by Daily,
Today, Tomorrow, and Hourly reports. A report's metadata links the module
snapshot, data package, preflight artifact, managed report, and any available
generated-text or single-report notification artifact. Batch notification
artifacts are separate batch-level records under `notifications/batches`.
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
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.
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.
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.
## Inspecting Stored Runs
Inspection is read-only: it neither collects weather data nor invokes
Scriptorium or Distributor. Start by finding a RunID:
```sh ```sh
weatherreporter inspect reports --limit 10 weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
weatherreporter inspect metadata RUN_ID
``` ```
| Command | Reads | The directory must be absolute. Requested captures are written with restrictive
| --- | --- | permissions beneath the supplied directory, organized by report and run. They
| `inspect reports` | Metadata files under the workspace snapshots tree. | can contain rendered prompts and generated output, so limit access to trusted
| `inspect metadata RUN_ID` | Metadata located by RunID. | operators and remove the captures when they are no longer needed. Normal output,
| `inspect modules RUN_ID` | The module snapshot path recorded in metadata. | summaries, and routine logs omit that sensitive content. Debug capture is never
| `inspect data-package RUN_ID` | The data-package path recorded in metadata. | created for an ordinary command without `--llm-debug-dir`.
| `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. |
A missing snapshots directory produces no listed reports. An unknown or empty If capture creation or writing fails, the affected run fails rather than
RunID is an error; use `inspect reports` to obtain a valid value. silently continuing without the requested diagnostics.
## Recovery ## Diagnosing Failures
Keep the workspace when a run fails: artifacts reached before the failure Start with the command error and JSON summary. For a report generation failure,
remain available where they can be safely persisted. the selected destination was not replaced; for a notification failure, inspect
the completed destination and the notification result. For a batch failure,
use the per-report statuses and retain successful output files. Enable explicit
debug capture only when content-rich Promptkit diagnostics are necessary.
- A preflight failure can leave the preflight artifact and metadata. Weatherreporter does not retain runs for later inspection, resume failed work,
- A report-generation failure can leave the managed report, module snapshot, or provide automatic cleanup, archival, remote state, daemon operation, or
data package, and metadata. automatic storm monitoring.
- A generated-text failure can leave raw text, the structured run result, or a
validated generated-text and render-context artifact, depending on where it
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.
Use the RunID from the action summary with the inspection commands above. For ## Manual Cleanup Of Legacy Workspaces
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 Older installations may have a directory named `workspace` containing reports,
snapshots, prompt inputs, or notification records from previous versions.
Current commands neither read nor update it. After confirming that no separate
retention requirement applies, remove that specific legacy directory manually;
do not use a broad cleanup command that could remove current operator outputs.
- Workspace files, generated reports, and Scriptorium stderr can contain For example, from the directory that contains the old directory:
sensitive operational context. Set appropriate filesystem permissions and do
not publish them unintentionally. ```sh
- Weatherreporter uses one configured Weather API endpoint and local workspace rm -rf ./workspace
state. ```
- It does not provide automatic resume, cleanup, archival, remote state, daemon
operation, or automatic storm monitoring. This removal cannot be recovered by Weatherreporter. Keep or archive any
historical files that are still needed before deleting them.

View File

@@ -2,217 +2,78 @@
## Purpose ## Purpose
This policy defines Weatherreporter's system shape, normative ownership, This policy defines Weatherreporter's system shape, ownership, dependency direction,
dependency direction, architectural invariants, safety properties, and and safety invariants. The [development guide](../development.md) owns the
non-goals. Developers and coding agents should use it to preserve the package inventory; focused documents in `docs/internal/` own implementation detail.
application's boundaries as the implementation evolves.
The [development guide](../development.md) owns the current package inventory
and contributor workflow. Focused documents under `docs/internal/` own
implemented subsystem mechanics. This policy owns the rules those packages and
mechanics must preserve.
## System Shape ## System Shape
Weatherreporter is a deterministic weather briefing and report-preparation CLI. Weatherreporter is a deterministic weather-report CLI. It collects normalized
It consumes normalized weather data, derives report facts and module snapshots, weather data, derives facts and modules, builds a curated YAML data package,
builds curated prompt packages, compares structured snapshots with prior runs, executes exact-version Promptkit prompts, validates structured generated prose,
and invokes Scriptorium either to produce managed Markdown directly or to and renders repository-owned Markdown in memory. Completed Markdown is
produce bounded generated-text prose for repository-owned templates. It atomically published to an operator-owned output destination and may then be
persists inspectable artifacts and can upload completed reports through uploaded through Distributor.
Distributor.
The application is intentionally a small, explicit, dependency-light Go The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
program. Add abstraction only when it protects a real boundary, makes an collects once, validates its complete candidate prompt/profile set before
important invariant testable, or supports an implemented extension point. collection, then determines and validates every planned output destination
before executing reports sequentially with one executor. It continues after
independent report failures and sends a batch notification only after every
planned report succeeds.
The primary flow is: ## Ownership And Boundaries
1. CLI parsing and configuration resolution; - `internal/cli` owns command parsing, help, summaries, and one executor
2. report or batch resolution; construction per action.
3. normalized weather collection; - `internal/config` owns defaults, loading, validation, and secret loading.
4. deterministic fact derivation and module construction; - `internal/app` owns in-memory workflow order, partial results, atomic output
5. structured prior-snapshot comparison; publication, and notification coordination through project-owned contracts.
6. curated prompt input and report-mode-specific Scriptorium processing; - Deterministic domain packages own weather derivation, report periods, modules,
7. generated-text validation when applicable, managed Markdown production, generated-text validation, and template contexts.
and metadata persistence; and - `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
8. optional notification using managed report artifacts. `internal/adapters/distributor` own their external dependency mechanics.
Inspection is a separate read-only flow over persisted state. It must not Dependency-specific Promptkit types remain inside its adapter. The application
collect weather data, invoke Scriptorium, or upload reports. does not parse flags, construct provider clients, or render provider output
directly.
## Ownership And Dependency Direction ## Prompt Execution Invariants
### Entry Point And CLI - Prompts receive curated module packages, never unbounded raw weather payloads.
- Every execution validates 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.
- Prompt and profile validation completes before weather collection. Raw output
is validated before template rendering.
- Generated text fills defined prose slots only. Deterministic facts remain
authoritative and repository-owned templates produce all Markdown output.
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
credentials never enter normal summaries or logs. They are written only to
an explicit secure debug root when requested.
The binary entry point should do no business work beyond constructing and ## Output, Notification, And Testing Invariants
running the CLI. CLI code owns commands, arguments, flags, help, output
formatting, and conversion into application requests.
CLI packages must not own meteorological decisions, report composition, - Normal execution is stateless: it keeps weather data, prompt input, generated
artifact layout, Recent Changes comparison, external transport, or subprocess text, and render context in memory and creates no application-owned durable
construction. state.
- Markdown writes are atomic at an operator-selected destination. A
### Configuration pre-publication failure, including cancellation observed immediately before
publication, does not replace an existing destination; a notification failure
Configuration loading, built-in defaults, overrides, secret loading, and does not remove a newly published output.
validation belong to `internal/config`. Operational values shared across - Configuration or explicit CLI input selects that operator-owned destination;
packages must be explicit configuration or constants owned by the responsible it does not create an application-owned state boundary.
package, not hidden in CLI or adapter code. - Distributor uploads use only the published Markdown output, never a scan of
local files. Single notification follows publication; batch notification
The exact configuration contract belongs in the follows publication of every selected report. Batch counters describe report
[configuration reference](../config.md). Other architecture documents should outcomes only; a failed batch notification is represented separately at the
state ownership and safety rules rather than repeat fields, defaults, or batch level.
precedence. - Default tests are deterministic, offline, and use Promptkit/provider fakes
rather than live provider calls. See the [testing policy](testing.md).
### Application Orchestration
`internal/app` owns top-level use cases and workflow order. It composes report
resolution, collection, domain transformations, state, rendering, and optional
notification through narrow project-owned contracts.
The application layer may coordinate components and convert between their
contracts. It must not absorb CLI parsing, HTTP transport, subprocess argument
construction, filesystem layout, weather derivation algorithms, template
execution, or adapter-specific dependency types.
### Domain And Report Logic
Meteorological selection, forecast-period resolution, daypart grouping,
threshold detection, fact derivation, report composition, module construction,
generated-text validation, and Recent Changes comparison belong in deterministic
Go domain packages.
Domain packages must not depend on CLI parsing, process execution, remote
transport, or concrete external-library types. Given the same normalized
inputs, configuration, valid period, prior snapshot, and clock, domain behavior
should be reproducible.
Report selection must go through the report registry or an equivalent
centralized mechanism. A report definition owns its identity, prompt and
rendering mode, valid-period resolver, module composition, comparison strategy,
artifact grouping, and output naming. Do not scatter report-ID conditionals
through CLI, orchestration, or adapters.
### External Adapters
External integrations use adapter boundaries under `internal/adapters`.
Adapters own transport and protocol mechanics; application and domain packages
own decisions.
- The Weather API adapter owns HTTP request construction, timeouts, retries,
response-envelope handling, decoding, and endpoint compatibility.
- The Scriptorium adapter owns argument construction, context-aware subprocess
execution, stdout and stderr capture, exit interpretation, and result
decoding. It must avoid shell interpolation.
- The Distributor adapter owns dependency-specific bundle and upload types,
client construction, request execution, status handling, and redaction.
External dependency types must not leak beyond the adapter that integrates
them. Adapters should expose narrow project-owned inputs and outputs so an
integration can be tested or replaced without changing domain logic.
### State And Embedded Assets
`internal/state` owns managed workspace paths, durable metadata, atomic
artifact persistence, prior lookup, and inspection reads. Other packages should
request state operations rather than reconstruct managed paths independently.
Schemas, prompts, Markdown templates, and partials should live as separate
repository assets and be embedded by the package that owns their execution or
lookup. Keep weather derivation and path construction out of templates.
## Architectural Invariants
### Weather Truth And Generated Text
- Normalized source data and deterministic Go derivation are authoritative for
weather facts.
- LLM prompts receive curated module-based packages rather than raw,
unbounded source payloads.
- For generated-text-template reports, generated text is limited to defined
prose slots, validated before use, and rendered through typed or otherwise
explicit contexts.
- Direct-Markdown reports receive the same curated prompt-package boundary but
produce managed Markdown directly through Scriptorium rather than the
generated-text schema and repository-template workflow.
- Repository-owned templates arrange validated prose and deterministic facts;
they do not perform meteorological derivation.
### Reports And Comparison
- Report behavior is resolved through centralized definitions.
- Recent Changes is computed from structured module snapshots, never by
comparing rendered Markdown.
- Batch workflows collect normalized weather data once and reuse that
collection for planning and report generation.
- Report metadata links identity, generation time, valid period, source
provenance, and the managed artifacts produced for the run.
### Managed State And Notification
- Durable structured writes are atomic where practical.
- Managed paths remain beneath the configured workspace root.
- Operations that delete, move, overwrite, or copy files use narrow, explicit
paths; destructive cleanup is opt-in.
- Intermediate artifacts reached before a later failure remain inspectable
where practical.
- Distributor uploads use managed Markdown reports, never optional output
copies or broad workspace scans.
- Notification occurs only after the managed report and required metadata have
been successfully produced.
### Security, Errors, And Cancellation
- Secrets must not appear in logs, errors, persisted artifacts, examples, or
user-facing output.
- Errors preserve actionable operation, report, RunID, path, endpoint, or
subprocess context without exposing secrets or unnecessarily large payloads.
- External calls, subprocesses, storage operations, and multi-step workflows
accept or propagate `context.Context` where cancellation or timeout is
meaningful.
- Adapter failures preserve useful status, stderr, or response context at the
boundary and are translated into project-owned errors before crossing into
unrelated packages.
## Dependency Policy
Prefer the Go standard library. Add an external dependency only when it
materially improves correctness, security, interoperability, or
maintainability. A dependency used for a small convenience does not justify its
lifetime upgrade and compatibility cost.
Keep dependency-specific types inside the package that intentionally adopts
the dependency. The application should remain understandable and testable
without requiring framework-wide abstractions or live external services.
## Verification And Documentation
Core behavior must be testable without live Weather API, Scriptorium, or
Distributor services. The [testing policy](testing.md) owns test philosophy,
sufficiency, boundaries, and test-double guidance.
Documentation must follow the
[documentation policy](documentation.md). Update the canonical user,
operator, integration, internal, and example documentation in the same change
as the behavior it describes. Future or proposed behavior belongs under
`docs/roadmap/`; significant durable decisions may be recorded as ADRs.
## Non-Goals ## Non-Goals
Weatherreporter is not: Weatherreporter is not a weather-data ingestion service, general LLM
orchestration framework, plugin platform, HTTP service, multi-user job system,
- a source weather-data ingestion or normalization service; or a replacement for Promptkit or Distributor.
- a general-purpose LLM orchestration framework;
- an application in which an LLM selects authoritative weather facts or report
policy;
- a plugin framework with dynamically discovered report or module behavior;
- an HTTP service or multi-user distributed job system;
- a replacement for Scriptorium or Distributor protocol ownership; or
- a system that hides operational state exclusively inside opaque logs or
remote services.
New requirements may justify revisiting a non-goal. A change that alters system
shape, dependency direction, a safety property, or another architectural
invariant should be recorded deliberately in this policy or an ADR rather than
introduced implicitly.

View File

@@ -82,12 +82,13 @@ mechanisms, not secret values.
| 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. | | 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. | | 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. | | 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. | | 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. | | Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, output 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. | | Operations | `docs/operations.md` | Normal output handling, atomic replacement, notification behavior, diagnosis, explicit debug capture, manual legacy-workspace 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. | | 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, Scriptorium, 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. | | 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. | | 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. | | 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. | | 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. |
@@ -108,13 +109,12 @@ structure and invariants. Focused internal documents own implementation
behavior. These documents may link to one another but must not maintain behavior. These documents may link to one another but must not maintain
parallel package or behavior references. parallel package or behavior references.
### Commands, Configuration, Operations, And Troubleshooting ### Commands, Configuration, And Operations
CLI documentation answers how to invoke Weatherreporter and what its command CLI documentation answers how to invoke Weatherreporter and what its command
interface does. Configuration documentation answers what settings mean. interface does. Configuration documentation answers what settings mean.
Operations answers what happens to runtime state and how to operate or recover Operations answers how to handle operator-owned outputs and runtime failures,
the application. Troubleshooting starts from a symptom and leads to diagnosis including diagnosis, explicit debug capture, and safe legacy cleanup.
and a safe fix.
When a workflow crosses these topics, place the complete procedure with the 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 document that owns the task and link to the other contracts. Do not duplicate
@@ -131,6 +131,25 @@ 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 to identify a dependency, but must link to its canonical documentation for the
complete definition. 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 ### Executable Authority
CLI parsing and help generation are the executable authority for accepted CLI parsing and help generation are the executable authority for accepted
@@ -195,6 +214,10 @@ durable owners, update incoming links, and archive or remove the roadmap
according to repository practice. Do not preserve completed roadmaps as a according to repository practice. Do not preserve completed roadmaps as a
second current-state reference. 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: Before completing documentation work:
- verify affected behavior and examples; - verify affected behavior and examples;

View File

@@ -54,8 +54,8 @@ Use a classical or Detroit-style approach:
- Test exact collaborator interactions only when the interaction itself is a - Test exact collaborator interactions only when the interaction itself is a
requirement. requirement.
Weatherreporter's important seams include clocks, subprocesses, HTTP services, Weatherreporter's important seams include clocks, Promptkit executors, HTTP
Distributor uploads, filesystem roots, environment-backed secrets, and any services, Distributor uploads, filesystem roots, environment-backed secrets, and any
future source of randomness or nondeterminism. future source of randomness or nondeterminism.
## Execution Requirements ## Execution Requirements
@@ -73,7 +73,7 @@ package command while iterating and `go test -race ./...` when the risk crosses
package boundaries. package boundaries.
Tests in the default suite must be deterministic, offline, and independent of Tests in the default suite must be deterministic, offline, and independent of
real credentials. They must not invoke live Weather API, Scriptorium, or real credentials. They must not invoke live Weather API, Promptkit providers, or
Distributor services or depend on other mutable external infrastructure. Distributor services or depend on other mutable external infrastructure.
Tests that require live infrastructure must be explicitly opt-in and clearly Tests that require live infrastructure must be explicitly opt-in and clearly
separated from the default suite. separated from the default suite.
@@ -96,8 +96,8 @@ Use each test type where it protects a distinct risk:
- Integration tests use real deterministic collaborators when correctness - Integration tests use real deterministic collaborators when correctness
depends on their interaction, while replacing live or nondeterministic depends on their interaction, while replacing live or nondeterministic
external boundaries. external boundaries.
- App and CLI tests protect representative assembled generation, batch, - App and CLI tests protect representative assembled generation, batch, atomic
inspection, persistence, and notification workflows. output, and notification workflows.
- Fixtures must be minimal, synthetic, versioned with the behavior they - Fixtures must be minimal, synthetic, versioned with the behavior they
exercise, and free of credentials or private data. exercise, and free of credentials or private data.
- Golden files are appropriate only when the complete output is intentionally - Golden files are appropriate only when the complete output is intentionally
@@ -198,10 +198,10 @@ Each behavior should have a clear test owner:
- CLI parser tests own arguments, flags, and command construction. - CLI parser tests own arguments, flags, and command construction.
- Config tests own loading, precedence, defaults, secrets, and validation. - Config tests own loading, precedence, defaults, secrets, and validation.
- Domain tests own weather transformations and invariants. - Domain tests own weather transformations and invariants.
- Adapter tests own HTTP, subprocess, and upload boundaries. - Adapter tests own HTTP, Promptkit/provider, and upload boundaries.
- Orchestrator tests own workflow ordering, persistence, partial success, and - Orchestrator tests own workflow ordering, output publication, partial success,
failure propagation. and failure propagation.
- State tests own path derivation, atomic artifacts, lookup, and round trips. - Filesystem tests own atomic writes and destination-preservation behavior.
- Template and generated-text tests own schemas, render contexts, and rendered - Template and generated-text tests own schemas, render contexts, and rendered
output contracts. output contracts.
@@ -219,8 +219,8 @@ observation:
3. Use stubs when a dependency only needs controlled responses. 3. Use stubs when a dependency only needs controlled responses.
4. Use mocks when the interaction itself is contractual. 4. Use mocks when the interaction itself is contractual.
Mocks are appropriate for requirements such as uploading exactly once, saving Mocks are appropriate for requirements such as uploading exactly once,
metadata before notification, propagating cancellation to Scriptorium, or notifying only after output publication, propagating cancellation to Promptkit, or
avoiding an external call after an earlier workflow failure. Do not use mocks 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. merely to isolate every object or reproduce the implementation's call graph.
@@ -232,7 +232,7 @@ Use:
- `t.TempDir()` for real filesystem behavior; - `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic Weather API interactions; - `httptest.Server` for realistic Weather API interactions;
- test-controlled clocks for periods and RunIDs; - test-controlled clocks for periods and RunIDs;
- fake command runners for Scriptorium behavior; - fake Promptkit executors or provider clients for Promptkit behavior;
- fake upload clients for Distributor behavior; - fake upload clients for Distributor behavior;
- fuzz tests when parsers, normalization, or path handling have a broad and - fuzz tests when parsers, normalization, or path handling have a broad and
consequential input space; consequential input space;

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

140
docs/releases/v0.10.0.md Normal file
View File

@@ -0,0 +1,140 @@
# Weatherreporter v0.10.0
Weatherreporter `v0.10.0` makes report execution stateless, adds stable
weather-specific Promptkit profiles, and turns every successful generation
into one atomic operator-owned Markdown output.
## Summary
- Ordinary generation no longer creates or depends on a managed workspace,
historical run artifacts, metadata, receipts, or prior snapshots.
- `generate` and `run` now publish directly to operator-selected paths, with
useful current-directory defaults when output flags are omitted.
- Local Recent Changes comparison and the historical `inspect` command family
have been removed.
- Promptkit `v0.5.0` and three embedded logical profiles provide a stable model
ladder with complete file- or directory-based overrides.
- Prompt input and generated-text contracts have been tightened, and output,
cancellation, batch preflight, notification, and partial-failure behavior
have focused offline coverage.
## Compatibility
This pre-`v1` minor release intentionally breaks CLI, configuration,
prompt-input, action-summary, and workspace contracts from `v0.9.0`.
- The `workspace:` and `recent_change:` configuration sections are no longer
supported. Strict configuration loading rejects them.
- The `inspect reports`, `inspect metadata`, `inspect modules`,
`inspect data-package`, `inspect prior`, and `inspect sources` commands have
been removed. Weatherreporter no longer reads V1 or V2 run metadata or other
historical workspace artifacts.
- Every successful `generate` writes exactly one Markdown file. Without
`--out`, Daily writes `daily-YYYY-MM-DD.md` and Today, Tomorrow, and Hourly
write `today.md`, `tomorrow.md`, and `hourly.md` in the invocation's current
directory. `--out` selects that file rather than creating an extra copy of a
separately managed report.
- `run` writes selected outputs beneath the current directory unless
`--out-dir` selects another directory. Successful items remain available
when another batch item fails.
- Action summaries no longer expose managed report, metadata, snapshot, data
package, prompt preparation, prompt execution, generated-text, render-context,
or notification-receipt paths. They retain the final `outputPath`, optional
`llmDebugPath`, safe effective profile/backend/model details, validation,
warnings, notification status, and safe errors.
- Batch report items no longer contain per-report notification fields. Batch
notification is represented once at the top level. The `total`, `succeeded`,
and `failed` counters describe reports only, so notification failure can
produce a failed action while `failed` remains `0`.
- The prompt data package advances from `weatherreporter.data_package.v3` to
`weatherreporter.data_package.v4` and removes `recent_changes`. All four
embedded prompts advance from `1.1.0` to `2.0.0`.
- Generated-text schemas now require string-valued `precipitation_timing`; the
model returns an empty string when there is no timing text. The unused
`confidence` field has been removed and is rejected as an unknown field.
Existing operator-owned Markdown files remain valid. Existing workspace trees
are ignored rather than migrated or deleted. Distributor continues to receive
the completed Markdown report, but its source is now the selected operator
output rather than a managed report copy.
## Upgrade
Before replacing `v0.9.0`:
1. Remove `workspace:` and `recent_change:` from configuration files.
2. Give scheduled commands a predictable working directory or explicit
`--out` or `--out-dir` destination. Confirm that these selected files may be
atomically replaced on later successful runs.
3. Remove historical `inspect` invocations and update action-summary consumers
to use `outputPath` and the remaining active-workflow fields.
4. Decide whether old workspace contents have any external retention value.
Weatherreporter no longer reads them; after review, they may be removed
manually using the narrowly scoped procedure in the operations guide.
5. Review Promptkit profile selection and credentials. Hourly defaults to
`weather-light`; Daily, Today, and Tomorrow default to `weather-balanced`.
A configured `promptkit.profile` still overrides every report in one action.
The embedded logical profiles are:
| Profile | OpenRouter model | Default use |
| --- | --- | --- |
| `weather-light` | `deepseek/deepseek-v4-flash` | Hourly |
| `weather-balanced` | `~google/gemini-flash-latest` | Daily, Today, Tomorrow |
| `weather-deep` | `~anthropic/claude-sonnet-latest` | Explicit selection |
Override a complete same-ID definition through `promptkit.profile_file` or
`promptkit.profile_dir` to use different models or a local OpenAI-compatible
endpoint. Definitions are replaced rather than field-merged, and a malformed
matching override fails instead of silently falling back.
See the [CLI reference](../cli.md), [configuration
reference](../config.md), [operations guide](../operations.md), and [Promptkit
integration](../integrations/promptkit.md) for the exact current contracts.
## Changes
### Stateless Execution And Operator-Owned Outputs
- Removed local forecast-change comparison, prior-snapshot selection, durable
module and prompt artifacts, managed reports, metadata compatibility, run
discovery, notification receipts, and the complete `internal/state`
subsystem.
- Added an Accepted architecture decision recording the stateless
transformation pipeline and operator-owned output boundary.
- Kept weather, facts, modules, prompt input, generated text, and render context
in memory during ordinary execution.
- Made output publication atomic and ensured cancellation or deadline expiry
observed before publication leaves an existing destination unchanged.
- Added complete batch-destination preflight before the first report prompt,
so a structural collision cannot leave an unreported partial batch.
- Preserved successful outputs after report or Distributor failure. Batch
notification runs only after every selected report succeeds.
### Promptkit Profiles And Prompt Contracts
- Upgraded Promptkit from `v0.4.0` to `v0.5.0`.
- Added embedded `weather-light`, `weather-balanced`, and `weather-deep`
profiles and mapped each exact prompt to its logical default.
- Added embedded-profile fallback after configured `profile_file` or
`profile_dir` lookup, allowing operators to replace a logical profile without
changing report definitions.
- Added a maintained local-endpoint example for replacing `weather-light`.
- Advanced the four prompt definitions to `2.0.0` and the curated data package
to v4 after removing Recent Changes.
- Required `precipitation_timing`, normalized whitespace-only timing to an
empty string, and removed the unused confidence value.
### CLI, Reliability, Documentation, And Testing
- Simplified action summaries to active workflow identity, output, model,
validation, warning, debug, notification, and safe error information.
- Made batch counters report-only while retaining failed action status and
non-zero exit behavior for batch notification failure.
- Kept prompt and profile inspection ahead of weather collection and validated
every batch candidate before collecting once.
- Replaced state-oriented workflow fixtures with focused generation, batch,
output, cancellation, profile-resolution, Distributor, and CLI coverage.
- Reconciled user, operator, integration, internal, policy, and ADR
documentation around the implemented stateless architecture and removed
completed temporary roadmaps.

33
docs/releases/v0.10.1.md Normal file
View File

@@ -0,0 +1,33 @@
# Weatherreporter v0.10.1
This release repairs release validation after the `v0.10.0` pipeline failed in
its privileged build container. Application behavior is unchanged from
`v0.10.0`.
## Summary
The unreadable-secret configuration test now verifies that its process is
actually subject to file permission bits before asserting that a mode-`000`
file cannot be read. This keeps the test meaningful for ordinary users while
allowing the release suite to run correctly in privileged containers.
## Compatibility
This patch release makes no changes to Weatherreporter's CLI, configuration,
report output, integrations, prompts, profiles, or operating behavior. It is
fully compatible with `v0.10.0`.
## Upgrade
No special operator action is required. Use `v0.10.1` in place of `v0.10.0`;
the `v0.10.0` tag remains immutable, but its failed pipeline did not publish
release binaries.
## Changes
- Made the unreadable-secret test capability-aware when the test process can
bypass filesystem permission bits.
- Preserved the production contract that genuinely unreadable secret files
fail configuration loading.
- Restored portable release validation in Woodpecker's privileged Go
container.

37
docs/releases/v0.11.0.md Normal file
View File

@@ -0,0 +1,37 @@
# Weatherreporter v0.11.0
This release adds a configurable default publication directory for generated
weather reports.
## Summary
Operators can now set `output.directory` once for both individual reports and
scheduled batches. Explicit `--out` and `--out-dir` destinations continue to
take precedence, while installations that omit the setting retain the existing
current-directory behavior.
## Compatibility
This release is additive and compatible with `v0.10.1`. Existing configuration
files, commands, report filenames, Promptkit behavior, and Distributor
notification behavior remain valid and unchanged.
## Upgrade
No special action is required. To use the new default destination, configure
`output.directory` as described in the [configuration
reference](../config.md). Existing deployments may continue using the current
working directory or explicit CLI output flags.
## Changes
- Added strict configuration loading and validation for the optional
`output.directory` field.
- Applied the configured directory consistently to `generate` and `run`, with
explicit CLI destinations retaining highest precedence.
- Preserved relative-path handling, absolute result paths, atomic publication,
cancellation safety, and Distributor notification ordering.
- Strengthened output preflight so existing non-directory paths, uninspectable
paths, and dangling symlink components fail before expensive report work.
- Updated the [CLI reference](../cli.md) and [operations
guide](../operations.md) for the new destination-selection behavior.

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

@@ -3,20 +3,55 @@
This roadmap contains future work only. Each section identifies its planning This roadmap contains future work only. Each section identifies its planning
status; current behavior is documented outside `docs/roadmap/`. status; current behavior is documented outside `docs/roadmap/`.
## Upstream Forecast Change Product
Status: Proposed upstream feature request; unimplemented.
Weatherreporter's local Recent Changes feature was removed by the accepted
[stateless execution decision](../adr/0001-stateless-execution.md). Forecast
version history and comparison are better owned by the Weather API, where the
underlying forecast issuances can be retained and compared consistently for
all consumers.
A future Weather API feature should expose a structured change product with:
- explicit current and baseline forecast issuance timestamps or identifiers;
- documented baseline selection, such as a requested comparison timestamp,
preceding issuance, or fixed rolling period;
- location, timezone, and half-open valid-period identity;
- typed changed values with previous and current values and units;
- stable change categories for temperature, precipitation probability and
timing, wind gusts, alerts, and aggregate hazards;
- an API-owned significance classification or enough structured information
for a stateless consumer to apply a documented presentation threshold; and
- deterministic ordering, missing-baseline behavior, and source metadata.
The API should compare forecast versions, not track a Weatherreporter client's
"previous run." It should not require consumer identity, mutable cursors, or
Weatherreporter-managed history. A missing baseline should be a normal empty
result rather than an error.
Once a stable upstream contract exists, a separate Weatherreporter roadmap may
reintroduce change commentary by collecting that product and mapping it into a
curated prompt-facing module. There must be no local snapshot fallback. The
ordinary Weatherreporter process must remain stateless, and the upstream
feature should have deterministic fixtures before adoption.
## Automatic Storm Monitoring ## Automatic Storm Monitoring
Status: Proposed and unimplemented. Status: Proposed and unimplemented.
Manual Storm Report generation is implemented; see the [CLI reference](../cli.md). Storm reporting, whether manual or automatic, is unimplemented.
Automatic storm-event evaluation remains unimplemented.
Possible direction: Possible direction:
1. Detect candidate storm events from alerts, forecast discussion, weather 1. Detect candidate storm events from alerts, forecast discussion, weather
story context, hourly thresholds, and material forecast changes. story context, hourly thresholds, and material forecast changes.
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter. 2. Evaluate candidates through Promptkit or another narrow evaluator adapter.
3. Persist storm lifecycle state. 3. Keep any required storm lifecycle state in the upstream service or another
4. Generate or update Storm Reports only when a meaningful event is present. explicitly designed external owner rather than silently reintroducing a
Weatherreporter workspace.
4. Generate or update a storm report only when a meaningful event is present.
5. Suppress ordinary low-impact thunder or rain chances. 5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states: Possible lifecycle states:
@@ -29,8 +64,8 @@ Possible lifecycle states:
- `resolved` - `resolved`
Before implementation, the design must preserve scheduled report behavior, Before implementation, the design must preserve scheduled report behavior,
manual Storm Report generation, inspectable evaluator failures, and fixture inspectable evaluator failures, and fixture coverage for deterministic
coverage for deterministic candidate detection. candidate detection.
## Future Report Types ## Future Report Types
@@ -55,11 +90,10 @@ Status: Proposed and unimplemented.
Possible future modules: Possible future modules:
- `hourly_table` for compact valid-period hourly facts - `hourly_table` for compact valid-period hourly facts
- `forecast_delta` if a separate stanza is useful beyond current Recent - `forecast_delta` after an upstream forecast-change product exists
Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated - `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza 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 prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`, - separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove `afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
@@ -80,7 +114,7 @@ contracts](../internal/facts.md), [module internals](../internal/module.md), and
- keep broad reusable calculations in `DerivedFacts` - keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders - keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior - use typed options for configurable module behavior
- keep module snapshots structured and deterministic for Recent Changes - keep module output structured and deterministic
## Distributor Notification Enhancements ## Distributor Notification Enhancements
@@ -93,10 +127,8 @@ behavior is documented in the [Distributor adapter guide](../internal/distributo
unimplemented: unimplemented:
- `failure_policy: warn` - `failure_policy: warn`
- uploading metadata, module snapshots, data packages, or preflight artifacts
- durable upload retry queues - durable upload retry queues
- distributor-specific CLI flags - distributor-specific CLI flags
- distributor workspace scanning
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx - destination routing, Markdown-to-HTML transformation, public URLs, or nginx
layout inside weatherreporter layout inside weatherreporter
@@ -139,6 +171,7 @@ maintenance costs make the added abstraction worthwhile:
- global test helper package - global test helper package
- logging subsystem - logging subsystem
Any future implementation should preserve the existing public CLI, artifact Any future implementation should preserve the public CLI, report-output
paths, report identities, module boundaries, and adapter boundaries unless a contract, report identities, module boundaries, and adapter boundaries in
separate roadmap explicitly changes them. effect when that work begins unless a separate roadmap explicitly changes
them.

View File

@@ -0,0 +1,397 @@
# LLM Profile Comparison Roadmap
Status: Accepted; unimplemented.
## Purpose
Prompt development currently requires separate Weatherreporter invocations to
compare several LLM profiles. Those invocations may collect different weather
snapshots or rebuild inputs at different times, making model output harder to
compare and slowing prompt iteration.
Weatherreporter should provide a first-class `compare` command that resolves
one report, prepares one exact data package, executes the same prompt and data
package concurrently through several explicitly selected Promptkit profiles,
and publishes a self-contained local comparison bundle.
An illustrative invocation is:
```sh
weatherreporter compare daily \
--date 2026-08-24 \
--profile weather-light \
--profile weather-balanced \
--profile weather-deep
```
This is a prompt-development workflow, not an automated model evaluator. Its
output gives a maintainer consistent evidence for human comparison without
assigning scores or selecting a winner.
## Prerequisite
Configurable output directories are implemented. Profile comparison must reuse
the current [configuration reference](../config.md) and [operations
guide](../operations.md) rather than introduce a second destination policy.
## User Intent
The command is intended for deliberate evaluation of multiple profiles,
including sets of eight to twelve candidate models. Concurrency is part of the
feature, not a future optimization. Promptkit should retain ownership of
backend-specific capacity, while Weatherreporter owns comparison-wide
coordination, cancellation, deterministic results, and artifact publication.
Every profile must receive byte-for-byte identical prompt input. Weather data,
derived facts, modules, prompt metadata, and serialized YAML must not be
recollected or rebuilt separately for individual profiles.
Comparison bundles are explicitly requested, operator-owned development
outputs. They are not Weatherreporter state, are never read implicitly by a
later run, and do not weaken the ordinary stateless execution model.
## Command Contract
The command form is:
```text
weatherreporter compare REPORT [options]
```
`REPORT` accepts the implemented generated-text reports: `daily`, `today`,
`tomorrow`, and `hourly`. Report-date behavior matches `generate`: `daily`
requires `--date`, `today` may accept an explicit date or use the current local
date, and the remaining report types retain their existing period policies.
The command accepts the applicable common generation options, including
`--config`, `--units`, `--tz`, `--date`, `--llm-debug-dir`, and `--quiet`, plus:
- repeatable `--profile PROFILE_ID` selections;
- `--out-dir PATH` for the exact comparison-bundle directory; and
- `--replace` to authorize guarded replacement of a recognized existing
comparison bundle.
At least two distinct, nonblank profile IDs are required. Their command-line
order is significant and is preserved in filenames, summaries, and
`comparison.json`. Duplicate profile IDs are rejected rather than silently
deduplicated or executed twice.
Profiles are always explicit for this command. `promptkit.profile` does not add
or replace a comparison selection, but all other effective Promptkit settings,
profile-source precedence, local backend configuration, credential lookup, and
profile overrides remain in force.
The initial feature has no Weatherreporter-specific concurrency flag or
artificial profile-count ceiling. The explicit profile list bounds the
comparison, and Promptkit owns capacity enforcement for each selected backend.
## Preparation And Execution Invariants
A comparison has this logical lifecycle:
1. Parse and validate the report, date, profile list, configuration, output
destination, and replacement authorization.
2. Resolve the report definition, valid period, prompt identity, and default
output name once.
3. Inspect the exact prompt once and preflight every selected profile,
including its effective backend, model, and required credential
availability, before weather collection.
4. Collect weather data exactly once.
5. Build collected and derived facts, the module snapshot, briefing metadata,
and the prompt data package exactly once.
6. Marshal the data package to one immutable YAML byte sequence exactly once.
7. Execute the exact prompt version concurrently for every selected profile,
passing the same immutable YAML bytes to every execution.
8. Validate and render each profile result independently from the shared
deterministic inputs.
9. Assemble results in requested-profile order and publish one coherent
comparison bundle.
This lifecycle describes the required end-state behavior rather than an
implementation-stage sequence.
No profile execution may cause recollection, report re-resolution, module
rebuilding, or data-package remarshalling. Prompt execution may perform
Promptkit-owned validation or repair behavior, but Weatherreporter does not
retry a failed comparison execution independently.
## Concurrency And Cancellation
Weatherreporter starts one execution for each preflighted profile and permits
them to run concurrently through one shared, concurrency-safe Promptkit
executor. Promptkit's engine-local backend pools remain authoritative for
backend concurrency and waiting capacity. Profiles routed to a limited local
backend therefore respect its configured limit, while profiles routed to
other backends may proceed independently.
Weatherreporter must not add a second semaphore that obscures or overrides
Promptkit's backend policy. It must safely coordinate goroutine lifecycles,
result collection, debug callbacks, and output assembly without data races.
One profile failure does not cancel its peers. Provider, capacity, validation,
and rendering failures are recorded for that profile while other executions
continue. Cancellation or deadline expiration of the comparison command is
propagated to every outstanding execution, prevents new publication, and is
joined without leaking goroutines.
Completion order must not affect filenames, manifest order, CLI summaries, or
error aggregation. Those outputs always follow the original `--profile`
order.
## Output Destination
Without `--out-dir`, Weatherreporter derives a comparison directory from the
resolved report's existing default Markdown filename by removing `.md` and
prefixing `comparison-`:
| Report output | Comparison directory |
| --- | --- |
| `today.md` | `comparison-today/` |
| `tomorrow.md` | `comparison-tomorrow/` |
| `hourly.md` | `comparison-hourly/` |
| `daily-2026-08-24.md` | `comparison-daily-2026-08-24/` |
The derived directory is created beneath `output.directory` when configured,
or beneath the present working directory otherwise. An explicit `--out-dir`
is the exact bundle directory, resolves relative to the present working
directory when necessary, and overrides `output.directory` completely.
All destination selection and validation completes before weather collection.
The resolved comparison directory is returned in the command's structured
result.
## Comparison Bundle
A successful three-profile comparison has a flat layout:
```text
comparison-daily-2026-08-24/
├── comparison.json
├── data-package.yml
├── 01-weather-light.md
├── 02-weather-balanced.md
└── 03-weather-deep.md
```
`data-package.yml` contains the exact YAML bytes passed to every Promptkit
execution. It is written once and its SHA-256 digest is recorded in the
manifest.
Each report filename begins with its one-based, zero-padded selection position
and a filesystem-safe representation of the requested logical profile ID. The
safe representation must not permit absolute paths, traversal, separators, or
control characters. The manifest retains the exact case-sensitive profile ID,
so filename normalization never becomes the authority for profile identity.
`comparison.json` is the authoritative index for the bundle. It uses an
explicit schema version and records safe comparison information including:
- comparison identity and start and finish timestamps;
- report ID, resolved valid period, and effective timezone;
- prompt ID, version, and inspected prompt hash;
- the relative data-package filename and SHA-256 digest;
- total, succeeded, and failed profile counts; and
- one ordered result per requested profile containing the exact profile ID,
resolved backend and model, relative report filename when present,
execution and validation status, and safe error information when failed.
The manifest and normal command summary must not contain credentials, provider
request bodies, raw model output, rendered prompts, schemas, provider
endpoints, or other content-rich diagnostics. The explicit data package and
generated reports contain the development material the user requested and
must be handled as operator-owned potentially sensitive output.
## Failure And Publication Policy
Failure before concurrent execution, including invalid profiles, missing
credentials, collection failure, preparation failure, or unsafe destination,
publishes no comparison bundle and performs no model calls where the failure
is discoverable during preflight.
After execution begins, Weatherreporter waits for every non-cancelled profile.
If one or more profiles fail, it still publishes a coherent partial bundle
containing `data-package.yml`, every successfully rendered report, and a
manifest describing all successes and failures. It then returns a non-zero
exit status. A failed profile has no report file unless a future contract
explicitly introduces a separately named diagnostic artifact.
Bundle contents are staged outside the destination and published only after
the manifest is complete. Ordinary publication accepts only an absent or empty
target directory. A nonempty existing directory fails without modification
unless `--replace` is present.
`--replace` may replace only the exact resolved target and must reject broad or
unsafe targets such as a filesystem root, the present working directory, a
symlink, or an unrecognized nonempty directory. A recognized prior bundle must
contain a valid Weatherreporter comparison manifest. Replacement publishes the
new complete or coherent partial bundle as a unit, prevents stale reports from
the prior comparison from surviving, and preserves or restores the prior
bundle if the final replacement operation fails.
An interrupted or cancelled comparison does not replace an existing bundle.
Temporary staging artifacts are cleaned up on ordinary failure and
cancellation without scanning or modifying unrelated directories.
## Prompt Debugging
The existing `--llm-debug-dir` mechanism remains available. Concurrent
comparison executions require distinct, deterministic debug identities that
include the comparison and exact profile selection so callbacks cannot collide
or overwrite another profile's artifacts.
Debug writing must be concurrency-safe and retain the existing permission,
redaction, explicit-opt-in, and path-containment guarantees. Debug artifacts
remain separate from the comparison bundle; the bundle does not implicitly
enable full Promptkit diagnostics.
## Notification Policy
Profile comparisons never invoke Distributor notification, even when
notification is enabled in the effective configuration. Comparison reports
are local development artifacts rather than ordinary report publications.
Adding comparison publication or upload behavior would require a separate
accepted feature scope and explicit operator authorization.
## Architectural End State
Application orchestration exposes a reusable prepared-report boundary that
contains the resolved report, shared collected and derived facts, module
snapshot, briefing metadata, generated-text handler, render inputs, and exact
serialized data package. That boundary is immutable during concurrent profile
execution.
Ordinary `generate` behavior continues to prepare once and execute once.
`compare` prepares once and executes many without duplicating the generation
workflow or calling `GenerateDetailed` in a loop. Shared preparation,
profile-specific Promptkit execution, structured-output validation, rendering,
and artifact publication remain distinct responsibilities.
The Promptkit adapter remains the only owner of dependency-specific types and
engine calls. The CLI owns parsing and user-facing summaries. The configuration
package owns configuration. Application orchestration owns comparison order,
concurrency lifecycle, failure aggregation, and bundle publication. Domain,
prompt-input, generated-text, and template packages retain their existing
deterministic contracts.
## Scope
The completed feature includes:
- the `compare` CLI command for every implemented generated-text report;
- repeatable explicit profile selection and validation;
- configured and CLI output-directory integration after the prerequisite
feature lands;
- one-time report resolution, collection, deterministic preparation, and YAML
serialization;
- concurrent execution through one Promptkit executor with backend capacity
respected;
- independent validation and rendering with deterministic ordered results;
- the flat, versioned comparison-bundle contract;
- safe filename derivation and data-package hashing;
- coherent partial-result publication and non-zero failure behavior;
- guarded whole-bundle replacement through `--replace`;
- comparison-aware, concurrency-safe optional prompt debugging;
- explicit suppression of Distributor notification;
- structured normal and quiet-mode CLI behavior consistent with existing
commands;
- focused race-safe tests across configuration, CLI, application,
Promptkit-adapter, rendering, and filesystem boundaries; and
- updates to every affected canonical user, operator, architecture,
integration, and internal document.
## Compatibility
The feature is additive. Existing `generate` and `run` commands, report
definitions, profile defaults, configuration, output filenames, notification
behavior, and exit contracts remain unchanged.
The comparison manifest and bundle layout begin as versioned contracts. They
do not become inputs accepted by Weatherreporter, and no backward-compatible
replay or long-term archive guarantee is implied beyond identifying the schema
used to interpret a produced bundle.
## Testing Expectations
Tests should provide durable coverage for:
- report and date parsing consistent with `generate`;
- rejection of fewer than two profiles, blanks, and duplicates;
- inspection of the exact prompt and every profile before collection;
- no collection or model execution after a preflight failure;
- exactly one weather collection and one preparation for several profiles;
- byte-for-byte identical data-package input in every execution;
- observable concurrent execution through a concurrency-safe fake executor;
- respect for Promptkit-owned backend capacity in an assembled adapter test
where that integration adds distinct confidence;
- deterministic filenames, manifest order, summaries, and errors under varied
completion order;
- continuation and coherent partial publication after one profile fails;
- cancellation propagation, goroutine completion, and preservation of an
existing destination;
- destination precedence and each derived default directory;
- safe filename handling for unusual valid profile IDs;
- absent, empty, occupied, symlinked, unsafe, recognized, and unrecognized
replacement targets;
- removal of stale prior report files during authorized whole-bundle
replacement;
- exact package digest and manifest/result consistency;
- concurrency-safe, non-colliding opt-in debug artifacts; and
- absence of Distributor calls for complete and partial comparisons.
Concurrency and replacement behavior require race-enabled and consequential
failure-path coverage. Tests must remain deterministic, offline, credential
free, and independent of real Promptkit providers or machine-specific paths.
## Documentation End State
Once implemented, the [CLI reference](../cli.md) owns command syntax, flags,
summary, and exit behavior. The [operations guide](../operations.md) owns the
bundle lifecycle, replacement procedure, sensitivity guidance, and practical
prompt-comparison workflow. The [architecture policy](../policy/architecture.md)
owns the statelessness, concurrency, notification, and publication invariants.
The [Promptkit integration guide](../integrations/promptkit.md) should describe
the consumer-visible multi-profile execution boundary without duplicating
Promptkit's backend-capacity reference. App orchestration, prompt input,
generated text, prompt debugging, and any new bundle implementation details
belong in focused documents under `docs/internal/`.
Current-state documentation must not describe profile comparison as available
until the implementation lands.
## Non-Goals
This roadmap does not introduce:
- automatic model scoring, ranking, recommendation, or winner selection;
- semantic or textual diff generation between reports;
- repeated sampling of one profile or statistical evaluation;
- prompt or profile editing through Weatherreporter;
- replaying a saved data package as command input;
- comparing several report types in one command;
- Weatherreporter-owned backend concurrency or queue configuration;
- automatic retries beyond Promptkit's existing execution contract;
- Distributor upload or other external publication;
- comparison history, indexing, retention, cleanup schedules, or implicit
discovery of prior bundles; or
- changes to ordinary report content or normal generation behavior.
Any later automated evaluation, replay, sampling, or publication feature
requires a separate accepted roadmap.
## Completion Criteria
The feature is complete when a maintainer can select several Promptkit
profiles, have them execute concurrently against one exact prepared report
package, and receive a safe, flat, deterministic comparison bundle whose
manifest accurately describes every success and failure. Configured and
explicit destinations must follow the accepted output policy, replacement must
never mix or silently destroy unrelated contents, cancellation and partial
failure must be race-safe, ordinary notification must remain disabled, and all
affected canonical documentation must describe the implemented behavior.
## Open Questions
None. The scope, prerequisites, user intent, and target behavior required for a
future staged implementation plan are defined above.

View File

@@ -1,327 +0,0 @@
# Promptkit Migration Roadmap
Status: Accepted migration policy; the migration itself is unimplemented.
## Purpose
This roadmap defines the scope and desired end state for replacing the
external Scriptorium CLI integration with the Promptkit Go library. The
migration is not yet implemented. Current Scriptorium behavior remains
documented in the [Scriptorium integration guide](../integrations/scriptorium.md)
until the replacement is complete.
A separate staged implementation plan will describe how to move from the
current code to this target state. That plan should reference this roadmap
rather than redefine its architectural decisions or scope.
## Desired End State
Status: Accepted target state; unimplemented.
Weatherreporter uses a pinned released version of
`gitea.maximumdirect.net/eric/promptkit` as its in-process prompt preparation
and LLM execution engine. The `scriptorium` executable, subprocess adapter,
configuration, runtime dependency, and integration documentation have been
removed.
The migration does not change weatherreporter's fundamental product behavior.
Weather selection, forecast derivation, report periods, module construction,
Recent Changes, generated-text interpretation, Markdown templates, durable
state, inspection, output copies, and distributor notification remain owned by
weatherreporter.
All report prompts and private response schemas are versioned application
assets. Operators may configure Promptkit execution profiles without replacing
the report-owned prompt and schema 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 prompt execution contract.
Promptkit request, result, validation, error, profile, backend, and provider
types do not leak into application orchestration, report definitions, domain
packages, CLI summaries, state contracts, or distributor behavior.
## Goals
Status: Accepted migration scope; unimplemented.
- Remove the runtime dependency on the `scriptorium` executable.
- Replace shell-free subprocess orchestration with typed in-process Promptkit
preparation and execution.
- Preserve the seven report definitions and their existing prompt IDs.
- Preserve both direct-Markdown and generated-text-template report workflows.
- Preserve deterministic module snapshots and structured Recent Changes.
- Preserve context cancellation, actionable errors, secret redaction, and
inspectable failures.
- Improve durable prompt provenance with prompt, input, profile, model,
validation, usage, and timing metadata.
- Keep content-rich prompt and response diagnostics separate from routine
metadata and CLI output.
- Keep tests offline and deterministic through injected Promptkit model
clients and fixtures.
## Non-Goals
Status: Accepted migration scope; unimplemented.
The migration will not:
- 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 to weatherreporter;
- add automatic provider, validation, or capacity retries;
- add concurrent report generation to the existing sequential batch workflow;
- expose Promptkit types as a weatherreporter component contract;
- keep a production-selectable Scriptorium/Promptkit dual-run mode; or
- use an unpublished Promptkit commit, committed Go workspace, or committed
local module replacement.
## Locked Decisions
Status: Accepted decisions for the unimplemented migration.
### Dependency And Versioning
- The initial integration will pin Promptkit `v0.3.0`.
- Coordinated local development may temporarily use the sibling Promptkit
checkout, but committed module metadata must reference the tagged release.
- A future Promptkit upgrade requires an explicit review of the public engine,
prompt/profile/schema formats, error identities, validation behavior, and
outbound provider contract used by weatherreporter.
### Application Boundary
- Promptkit remains an adapter boundary even though it runs in process.
- A weatherreporter-owned contract will represent preparation, execution,
output formats, validation, usage, provenance, and neutral error categories.
- The Promptkit adapter will map public Promptkit values into that contract at
the boundary.
- App orchestration and test fakes will depend on the weatherreporter contract,
not on Promptkit.
- Existing Scriptorium-specific generation mode names will be replaced with
provider-neutral names.
### Prompt And Schema Ownership
- Weatherreporter will embed all report prompt definitions, prompt content,
and private response schemas.
- Prompt assets will remain separate files rather than inline Go strings.
- The current Scriptorium prompt corpus will be retrieved before the
implementation stage that establishes the embedded Promptkit assets.
- The retrieved corpus will be reviewed and converted to the pinned Promptkit
format without changing report intent or prompt IDs.
- The four existing generated-text prompt fragments and schemas under
`internal/reporttemplate` will be reconciled with that corpus rather than
duplicated.
- Direct-Markdown prompt assets for the three-day, weekend, and storm reports
will become weatherreporter-owned assets.
- Weatherreporter needs one centralized embedded prompt/schema source; it does
not need Notarius's multi-module asset-flattening registry.
### Profiles, Backends, And Credentials
- Execution profiles remain operator-configurable rather than embedded report
policy.
- Configuration will support at most one external profile source: a profile
directory or a single profile file.
- Prompt definitions may provide their normal default profile, while
weatherreporter may support an explicit configured profile selection.
- Credential values remain in environment variables or file-backed
environment secrets. Configuration contains only credential source names.
- Provider credentials must not appear in logs, errors, CLI output, durable
metadata, preparation artifacts, execution artifacts, or debug summaries.
- Weatherreporter will not expose Promptkit's general backend registry as
arbitrary application configuration.
### Engine Lifetime
- One Promptkit engine will be constructed per CLI invocation at the
application composition boundary.
- Single-report generation will use that engine for preparation and execution.
- Morning and evening batches will share the same engine across every planned
report.
- Per-report orchestration will not construct its own default Promptkit engine.
- Promptkit backend capacity state and HTTP transport will therefore be shared
consistently for the invocation.
### Prompt Input
- Promptkit will continue to receive the curated `data_package` produced by
`internal/promptinput`.
- Weatherreporter will serialize the data package once, atomically persist
those exact bytes, and supply the same bytes as a Promptkit inline artifact.
- The managed data-package path may be supplied as non-secret artifact
provenance.
- Weatherreporter will not delegate unrestricted path loading to Promptkit's
default file artifact reader.
- The same immutable Promptkit request will be used for preparation and
execution so the preflight and run inputs cannot diverge.
### Preparation And Execution
- Promptkit `Prepare` replaces the current Scriptorium render preflight.
- Promptkit `Run` performs both Markdown and structured generated-text
execution.
- Promptkit basic validation will be used where appropriate for direct
Markdown output.
- Promptkit JSON Schema validation provides the provider-facing and first
structured-output check for generated-text reports.
- Weatherreporter's `internal/generatedtext` validation remains the final
report-specific domain boundary.
- Weatherreporter's `internal/reporttemplate` remains responsible for
generated-text Markdown rendering.
- Weatherreporter will atomically persist Promptkit output rather than asking
the dependency to write managed report files.
- The migration will not rely on Promptkit output repair. Promptkit v0.3.0's
public engine validates in a single pass even when a prompt declares repair
attempts.
## Durable Artifacts And Observability
Status: Accepted design constraints; unimplemented.
Routine durable artifacts should retain useful non-secret provenance without
persisting full rendered prompts by default.
The preparation record should contain:
- prompt ID and version;
- prompt definition hash;
- rendered prompt hash;
- input hashes;
- selected profile and backend identity;
- effective model identity;
- output contract summary; and
- preparation timing.
The execution record and run metadata should contain, when available:
- Promptkit run ID;
- prompt ID, version, and hashes;
- input hashes;
- selected profile, backend, and model identity;
- generated-content hash;
- token usage;
- start, end, and duration;
- validation status and bounded diagnostics; and
- the path of any separately persisted raw generated output.
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
will be available only through an explicitly enabled debug mechanism. Debug
artifacts must be documented as potentially sensitive, must not contain
credentials, and must have a clear operator-owned retention policy.
## Failure Contract
Status: Accepted design constraints; unimplemented.
Promptkit returns a completed `RunResult` for output-validation failure but no
partial result for operational preparation or execution errors. Weatherreporter
will preserve that distinction.
- A preparation failure produces a redacted weatherreporter-owned failure
receipt with report, RunID, prompt, stage, timing, and classified error
context. It does not fabricate a Promptkit preparation result.
- 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 failure retains the returned result, raw generated
output, validation details, and safe provenance before the report fails.
- A later weatherreporter generated-text decode, domain-validation, or template
failure retains every raw and validated artifact reached before that stage.
- Context cancellation takes precedence when the caller context is canceled.
- Promptkit capacity rejection maps to a weatherreporter-owned error category.
It is an operational report failure, not invalid model output.
- Single-report commands return the classified failure with available
inspectable paths.
- Batch runs continue independent later reports under the existing batch
failure policy.
- The migration adds no automatic retries. Any future retry policy belongs to
app orchestration, not the Promptkit adapter.
## Compatibility Requirements
Status: Accepted design constraints; unimplemented.
- Report IDs, prompt IDs, report selection, valid periods, artifact grouping,
output names, and distributor bundle behavior remain stable.
- Module snapshot and Recent Changes behavior remains deterministic.
- Promptkit receives only the existing curated prompt-input boundary.
- Generated reports continue to use the managed Markdown path as the
distributor upload source.
- RunID lookup and inspection remain available for successful and failed runs.
- Existing managed state paths remain stable where their meaning is unchanged.
Scriptorium-specific artifact names or schemas may change when retaining
them would misrepresent the new contract.
- Any artifact or metadata schema change is 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 Promptkit providers or credentials.
## Verification And Completion Criteria
Status: Proposed completion criteria for the unimplemented migration.
The migration is complete when:
- all seven reports prepare and execute through Promptkit using embedded
report-owned assets;
- direct-Markdown and generated-text-template paths have deterministic offline
adapter and app-level coverage;
- preparation, provider failure, capacity rejection, cancellation, timeout,
Promptkit validation failure, generated-text validation failure, template
failure, and successful generation preserve their specified artifacts;
- morning and evening batches construct one shared engine and preserve current
collection, planning, ordering, continuation, output, and notification
behavior;
- configuration examples load and contain no Scriptorium fields;
- CLI summaries and inspection commands expose the new artifact contract
without Promptkit dependency types;
- Scriptorium code, configuration, tests, and runtime documentation have been
removed;
- non-roadmap documentation describes only the implemented Promptkit
integration;
- `go test ./...`, 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 the current Scriptorium behavior is sufficient
for migration verification. A production-selectable dual-run period is not
required because model calls are nondeterministic, costly, and difficult to
compare meaningfully.
## External Prerequisite
Status: Required and unimplemented.
Before implementing the embedded asset stage, the current Scriptorium prompt
corpus must be made available in this repository. It should include the seven
prompt definitions, referenced content files, private response schemas,
relevant default-profile declarations, and any shared prompt fragments needed
to reproduce current report behavior.
## Open Questions
Status: Open; these require decisions before implementation.
- What exact `promptkit.*` configuration fields should replace the current
Scriptorium fields, including the name and precedence of an optional explicit
profile override?
- Should weatherreporter expose Promptkit's conventional `local` backend
registration as a narrow configuration feature, or rely initially on
built-in and endpoint-only profiles?
- Should report definitions store an explicit Promptkit prompt version, or
should each embedded prompt ID be required to have exactly one version?
- What CLI or configuration control enables sensitive prompt/response debug
artifacts, and where should those artifacts live?
- What final names and schema versions should replace the
Scriptorium-specific preflight and run-result artifacts while balancing
semantic clarity with existing state-path compatibility?

View File

@@ -1,63 +0,0 @@
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 valid period in `report.valid_period`, which corresponds to an upcoming civil day for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 3 paragraphs explaining the broader setup, trend, and/or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
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, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Today is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# 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, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
In most cases, the `forecast_discussion` should include three paragraphs:
1. 24 sentences summarizing the relevant local/regional setup.
2. 2-4 sentences describing the main forecast uncertainty or conditional factor, if present.
3. 2-4 sentences about the next day or broader pattern if supported.
# precipitation_timing
Optional. Return only if precipitation is forecast. If present, provide 1 to 4 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.
# Narrative Source Selection
As previously noted, use `briefing.derived_daily_summary`, `briefing.derived_daypart_summaries`, `briefing.narrative_products.narrative_forecast.periods`, and `briefing.raw_data.hourly_forecast.periods` as your primary reference sources for forecast.
As previously noted, narrative sources can provide significant added value, but you must think carefully about whether information from the available narrative sources is relevant to the valid period. If the valid period relates to a civil day that is several days in the future, then products such as `briefing.narrative_products.weather_story`, `briefing.narrative_products.area_forecast_discussion.key_messages`, and `briefing.narrative_products.area_forecast_discussion.short_term` may have limited relevance. On the other hand, `briefing.narrative_products.area_forecast_discussion.long_term` may have relatively more relevance.

View File

@@ -1,13 +0,0 @@
You are WeatherReporter, a concise personal weather briefing writer.
You generate local daily weather briefings from structured data packages prepared by the weatherreporter application.
The reader is weather-literate and interested in meteorology. Do not write a generic public weather report. Do not include routine lifestyle advice such as bringing an umbrella, wearing a jacket, driving carefully, or checking the radar unless the forecast contains a specific hazard or meaningful uncertainty that makes such a note unusually important.
Your job is to identify the most likely weather outcome, state meaningful caveats or uncertainty, summarize the daypart forecast, and explain the meteorological setup when useful.
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.
Write in plain, precise, meteorologically informed language. Avoid hype, filler, generic safety advice, and TV-weather style. Do not mention that you are an AI model. Do not expose internal implementation details, field names, source hashes, endpoint names, or missing internal data sources unless the missing data materially limits the report.
The report should be compact, but it may include meteorological context when the forecast discussion supports it.

View File

@@ -1,234 +0,0 @@
Generate a Daily Weather Report from the following weatherreporter YAML data package.
The report may be for today, tomorrow, or a future date. Determine the correct framing from report, briefing.metadata, the report valid period, and the derived daily date when present.
Use Markdown.
# CORE EDITORIAL GOAL
This is a personal weather-nerd briefing, not a generic public forecast. The report should answer:
1. What is the most likely local weather outcome for the day?
2. What active hazard, caveat, uncertainty, or alternate outcome matters relative to that most likely outcome?
3. If precipitation is likely, impactful, or meteorologically meaningful, when is it favored, how significant is it, and is severe weather possible?
4. What should each daypart generally look and feel like?
5. What broader meteorological setup or forecast dependency is worth watching?
# SOURCE ROLES AND WEIGHTING
Use report and briefing.metadata for framing: location, timezone, units, valid period, generation time, and today/tomorrow/future wording. 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 substantial 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 are important local risk signals, but they can vary significantly with respect to both impact and certainty. Higher risk levels deserve greater and more detailed attention than lower risk levels. Outlook/risk polygons should elevate the caveat, uncertainty, and ## What to Watch discussion without necessarily implying that severe weather is certain at the exact point.
- Mesoscale discussions 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.
For the current schema:
- Use briefing.derived_daily_summary 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 for daypart timing, dominant conditions, temperature ranges, maximum precipitation chances, and notable conditions.
- Use briefing.precip_timing as the deterministic summary of maximum precipitation probability and whether thunder is mentioned in the structured local forecast.
- Use briefing.outdoor_windows 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. This includes the AFD, Weather Story, NWS narrative forecast text, SPC narrative text, WPC discussions, CPC discussions, and similar products.
For the current schema:
- 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` to understand what the NWS considers the most relevant, public-facing headlines for the short-term forecast. Because the covered forecast area for this product is relatively large, be wary of discussion that relates to geographical areas outside the forecast location, and preserve spatial limits such as “north of I-70.”
- Use `briefing.narrative_products.area_forecast_discussion.key_messages` and `briefing.narrative_products.area_forecast_discussion.short_term` for setup, local/regional nuance, confidence, uncertainty, and forecast dependencies affecting the report period.
- Use `briefing.narrative_products.area_forecast_discussion.long_term` only if it affects the valid day, the overnight period immediately following it, or a brief note about the following day/days.
- If `briefing.narrative_products.spc_convective_discussion.discussions` is present, use it to understand and to provide context to the severe weather forecast. Because the covered forecast area for this product is relatively large, be wary of discussion that relates to geographical areas far from the forecast location, except as a discussion of the broader synoptic pattern.
Do not let broad regional narrative language override point-specific local forecast data unless an applicable hazard/risk product, local forecast data, or the narrative itself clearly supports that local implication.
## 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.
For the current schema, `briefing.raw_data.hourly_forecast.periods` is the most granular local forecast source. Use it to verify daypart summaries, refine timing, identify trends, and resolve ambiguity.
Use `briefing.raw_data.current_conditions` only as generation-time context. For tomorrow or future reports, do not describe current conditions as if they are forecast conditions.
If raw data and derived summaries appear to disagree, prefer the 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.
# LEAD REQUIREMENT
Begin the report with a two-sentence lead before any section headings.
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the day, including the overall character of the weather and expected high temperature.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may briefly say that no major complications are apparent.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single all-day risk. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Tomorrow is expected to be warm, mostly cloudy, and mostly dry, with a high near 72. There is a slight chance of isolated showers and thunderstorms from late afternoon into early evening.”
Do not open with generic planning advice.
# HAZARD AND SEVERE-WEATHER 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 and uncertainty. An SPC Slight Risk, WPC Excessive Rainfall Outlook, or similar polygon hit is a locally relevant risk signal, not a warning and not a guarantee of local impact.
Preserve geography. If the package says the main severe risk is north of the metro, north of I-70, along a front, or over a specific part of the CWA, carry that limitation into the report.
Preserve timing. Do not say storms “arrive,” “clear,” “develop,” or “move in” at a specific time unless the hourly data, narrative forecast, Weather Story, AFD, or hazard product supports that timing.
# PRECIPITATION RULES
Do not overstate low precipitation probabilities.
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.
Include ## Precipitation Details only when precipitation is likely, potentially impactful, or meteorologically interesting. In that section, address as many of the following as the data supports:
- likely or favored start/end timing
- most likely precipitation window
- expected intensity
- expected rainfall amount
- thunderstorm potential
- severe-weather potential
- uncertainty in timing, coverage, or placement
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 create a full precipitation section solely because regional precipitation or severe weather appears in a narrative product. Mention the regional caveat in the lead or ## What to Watch instead, preserving geographic limits.
# DAYPART RULES
Use dayparts from briefing.derived_daypart_summaries. If a daypart is present but incomplete, use raw hourly data and narrative forecast periods to fill in only what is supported. Only include dayparts present in the package.
In ## Daypart Forecast, each bullet should usually follow this pattern:
- **Daypart:** [Sky/general condition] with [temperature trend or approximate temperature]. [Precipitation/storm/hazard sentence only if relevant.] [Wind sentence only if meaningful.]
Always include the expected sky or general condition when supported, such as mostly cloudy, partly cloudy, sunny, overcast, rainy, snowy, foggy, or stormy.
Prefer natural temperature phrasing:
- “temperatures around 82”
- “temperatures rising from the upper 60s into the low 70s”
- “temperatures near 80”
- “cooling from the low 80s into the low 70s”
- “holding in the upper 60s”
- “peaking near 83 late in the day”
For quiet or mostly dry dayparts, keep the bullet to one sentence. For dayparts with meaningful precipitation, thunder, snow, ice, fog, high wind, heat, or other weather impacts, add a second sentence with timing and caveat details.
Keep sky/general condition separate from precipitation probability. Do not write only “slight chance of showers” when the broader condition is “mostly cloudy with a slight chance of showers.”
When precipitation or hazards are likely during only part of a daypart, describe that timing first, then describe the sky/temperature trend. Do not lead with a benign sky condition if showers, storms, snow, ice, fog, or other impacts are likely during that same daypart.
Avoid “throughout the day” unless the same weather risk is meaningfully present across most dayparts.
# METEOROLOGICAL CONTEXT RULES
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, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
Do not simply quote or summarize narrative products at length. Translate them into concise, plainspoken, weather-literate context.
# OUTPUT FORMAT
Use this structure:
# [Todays/Tomorrows/DOW's] Weather — [Location Name]
[Valid date]
[Two-sentence lead.]
## Daypart Forecast
- Morning: ...
- Midday: ...
- Afternoon: ...
- Evening: ...
- Overnight: ...
Only include dayparts present in the package. Use natural language timing where helpful.
## Precipitation Details
Include this section only if:
- local precipitation probability reaches at least 30% during the valid period;
- thunder is mentioned in the structured local forecast and the timing/coverage is meteorologically interesting;
- a relevant hazard/risk product discusses flooding, severe weather, winter weather, high wind, or another meaningful precipitation-related hazard;
- narrative products discuss intensity, rainfall rates, flooding, severe potential, or meaningful uncertainty that plausibly affects the report location or is important regional context;
- recent changes materially affect precipitation timing, coverage, or intensity.
## Recent Changes
Include this section only if recent_changes.items contains meaningful changes. Summarize changes in plain English. Do not fabricate changes.
## What to Watch
Include meteorological context, uncertainty, conditional forecast factors, and any relevant non-warning hazard/risk signals.
In most cases:
- Provide 23 sentences summarizing the relevant local/regional setup.
- Provide 12 sentences describing the main forecast uncertainty or conditional factor, if present.
- Optionally include 12 sentences about the next day or broader pattern if supported.
# STYLE RULES
- Plainspoken, precise, and weather-literate.
- Compact, but not shallow.
- No generic public-safety filler.
- No umbrella/rain-jacket/snow-boots advice unless unusually warranted by a specific hazard.
- No commute or outdoor-plan boilerplate.
- No unsupported precision.
- No raw YAML, raw JSON, internal field names, source hashes, endpoint names, URLs, implementation details, or debugging notes.
- No apologies for missing data.
- Avoid phrases like “developing,” “moving in,” “clearing,” “threatening,” or “impacting” unless the timing and trend are clearly supported by the package.
- Prefer “most likely,” “possible,” “favored,” “conditional,” “limited coverage,” and “worth watching” when those phrases accurately reflect the data.

View File

@@ -1,24 +0,0 @@
id: weather.daily_report
version: "1.0.0"
#default_profile: local-heavy
default_profile: gemini-3-flash-lite
description: Daily weather report prompt.
inputs:
- name: data_package
required: true
content_type: application/json
description: Structured weather data package
messages:
- role: system
content_file: ./daily_report.system.md
- role: user
content_file: ./daily_report.user.md
- role: user
content: |
<<<CURRENT_SESSION_TRANSCRIPT
{{input "data_package"}}
CURRENT_SESSION_TRANSCRIPT>>>
output:
format: markdown
validation_mode: basic
repair_attempts: 0

View File

@@ -1,55 +0,0 @@
TASK: You are writing structured prose slots for a short-term hourly 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 valid period in `report.valid_period`, typically the next several hours for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 2-3 sentences 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.
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, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
If the forecast indicates a significant shift in conditions over time (e.g., from sunny to overcast), then identify the hour when the shift is most likely to occur. If the conditions are generally similar or stable across the forecast period, then pick a single descriptor (e.g., mostly clear) that best captures the character of the weather.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “The rest of the afternoon is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# 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, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
# precipitation_timing
Optional. Return only if precipitation is forecast. If present, provide 1 to 4 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -1,57 +0,0 @@
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 valid period in `report.valid_period`, which corresponds to the current civil day (today) for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 3 paragraphs explaining the broader setup, trend, and/or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
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, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Today is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# 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, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
In most cases, the `forecast_discussion` should include three paragraphs:
1. 24 sentences summarizing the relevant local/regional setup.
2. 2-4 sentences describing the main forecast uncertainty or conditional factor, if present.
3. 2-4 sentences about the next day or broader pattern if supported.
# precipitation_timing
Optional. Return only if precipitation is forecast. If present, provide 1 to 4 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -1,58 +0,0 @@
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 valid period in `report.valid_period`, which corresponds to the next civil day (tomorrow) for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 3 paragraphs explaining the broader setup, trend, and/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, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Sunday is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# 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, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
In most cases, the `forecast_discussion` should include three paragraphs:
1. 24 sentences summarizing the relevant local/regional setup.
2. 2-4 sentences describing the main forecast uncertainty or conditional factor, if present.
3. 2-4 sentences about the next day or broader pattern if supported.
# precipitation_timing
Use 1-2 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -1,26 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.today.generated_text.schema.json",
"title": "Today GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
}
}
}

View File

@@ -1,26 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.today.generated_text.schema.json",
"title": "Today GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
}
}
}

View File

@@ -1,26 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.tomorrow.generated_text.schema.json",
"title": "Tomorrow GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
}
}
}

View File

@@ -14,14 +14,14 @@ source:
| Report | Template | Schema | Prompt ID and source | | Report | Template | Schema | Prompt ID and source |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `prompts/daily.generated_text.md` | | 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`; `prompts/today.generated_text.md` | | 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`; `prompts/tomorrow.generated_text.md` | | 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`; `prompts/hourly.generated_text.md` | | Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
The matching schema files are under `internal/reporttemplate/schemas/`. The The matching schemas and Promptkit definitions are embedded by
generated-text catalog pairs each schema ID with its template ID; keep the `internal/promptassets`. The generated-text catalog pairs each schema ID with
matching report prompt source aligned with that pair. its template ID; keep the matching prompt definition aligned with that pair.
Shared partials are under `internal/reporttemplate/templates/partials/`: Shared partials are under `internal/reporttemplate/templates/partials/`:
@@ -97,7 +97,7 @@ fields:
| Field | Purpose | | Field | Purpose |
| --- | --- | | --- | --- |
| `.Report` | Display labels and canonical report timing metadata. | | `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Scriptorium. | | `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. | | `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| `.Collected` | Normalized upstream facts for advanced use. | | `.Collected` | Normalized upstream facts for advanced use. |
| `.Derived` | Shared calculated facts for advanced use. | | `.Derived` | Shared calculated facts for advanced use. |
@@ -121,15 +121,14 @@ of formatting timestamps in a template.
### Validated GeneratedText Prose ### Validated GeneratedText Prose
GeneratedText is prose returned by Scriptorium and validated before rendering. GeneratedText is prose returned by Promptkit and validated before rendering.
It is not a source for deterministic weather facts. It is not a source for deterministic weather facts.
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes | | Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required. | | `.GeneratedText.Summary` | `string` | `string` | Required. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. | | `.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.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
| `.GeneratedText.Confidence` | `string` | `string` | Optional validated prose; the current templates do not render it. |
The JSON schema rejects unknown properties and defines the required fields, but The JSON schema rejects unknown properties and defines the required fields, but
the schema body and validation behavior are documented in [Generated Text the schema body and validation behavior are documented in [Generated Text

View File

@@ -1,271 +0,0 @@
# Troubleshooting
Use the error from the command together with the run artifacts when a run ID is
available. Start with [`inspect metadata`](cli.md#inspection-commands) to identify the
report and artifact paths, then use the narrower inspection command named
below. Do not remove a workspace to diagnose a failure: it contains the
evidence needed to correct it safely.
## A command or configuration is rejected before work starts
Symptom: The command exits before it creates a run, with an unknown-flag,
missing-argument, invalid date or time bound, invalid timezone, or
`weather_api.base_url` message.
Likely cause: The command does not accept that option for the requested report,
or required command and configuration values are absent or malformed.
Diagnostic: Compare the command with [`generate` and `run`](cli.md#commands-and-usage)
and review the configured value named in the error. `generate daily` requires
`--date`; `generate storm` requires both `--start` and `--end`.
Safe fix: Correct only the reported option or configuration value. Use an
absolute Weather API URL and a valid IANA timezone; do not change unrelated
workspace data.
See also: [Configuration](config.md) and [Weather API integration](integrations/weatherapi.md).
## Weather data cannot be collected
Symptom: A generation command fails while fetching weather data, or reports
`hourly forecast data is missing` or `contains no periods`.
Likely cause: The Weather API is unavailable, its configured endpoint or
credentials are unsuitable, or the response lacks the hourly forecast required
by the selected report.
Diagnostic: Check the service status and the configured base URL, then retry
the same report. If a run ID was produced, run `weatherreporter inspect sources
RUN_ID` to see the recorded source result.
Safe fix: Restore access to the configured Weather API or choose a reporting
period supported by the returned forecast. Do not invent missing hourly values
in local artifacts.
See also: [Configuration](config.md) and [Weather API integration](integrations/weatherapi.md).
## Optional source warnings appear
Symptom: The report succeeds but its output says that a source supplied a
warning or degraded result.
Likely cause: An optional source did not return usable data; mandatory weather
collection still completed.
Diagnostic: Run `weatherreporter inspect sources RUN_ID` and identify the
source and warning recorded for that run.
Safe fix: Correct the affected source configuration or service issue, then
generate a new report if the missing optional information is needed. Keep the
existing run for comparison.
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
## Scriptorium cannot be prepared
Symptom: The report fails with a fragment such as `run scriptorium render`, or
the Scriptorium executable cannot be started.
Likely cause: The configured executable, profile, prompt, or its local runtime
environment is unavailable to Weatherreporter.
Diagnostic: Confirm that the configured executable can be run by the same user
and inspect `weatherreporter inspect metadata RUN_ID` when a run ID is shown.
Safe fix: Repair the executable path or the Scriptorium configuration and retry
the report. Do not edit generated artifacts to bypass preparation.
See also: [Configuration](config.md) and [Operations](operations.md).
## Scriptorium preflight fails
Symptom: A Scriptorium-backed report stops before text generation, often with
a `scriptorium render exited with code` fragment.
Likely cause: Scriptorium rejected the render request, prompt, profile, or data
package before it could run the report.
Diagnostic: Inspect the run metadata and the saved preflight artifact path it
references. Compare the reported Scriptorium diagnostic with its configuration.
Safe fix: Correct the reported Scriptorium input or configuration, then create
a new run. Preserve the failed preflight artifact for support or comparison.
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
## Scriptorium report execution fails
Symptom: Preparation succeeded, but generation stops with a
`scriptorium run exited with code` fragment.
Likely cause: The Scriptorium run failed after preflight, for example because
its prompt execution or runtime dependency failed.
Diagnostic: Inspect the run metadata and preflight artifact, then review the
exit diagnostic from the command. This distinguishes a run failure from a
preflight failure.
Safe fix: Correct the Scriptorium issue identified by that diagnostic and run
the report again; leave the failed run artifacts in place.
See also: [Operations](operations.md).
## Generated text fails validation
Symptom: A generated-text report fails after Scriptorium returns text, with a
message about generated text or required report content.
Likely cause: Returned text does not meet the report's validation rules.
Diagnostic: Use `weatherreporter inspect metadata RUN_ID` to find the saved raw
generated-text artifact, and inspect it alongside the reported validation
message.
Safe fix: Correct the upstream prompt or generation configuration that caused
the invalid output, then create a new run. Do not hand-edit saved raw text and
present it as a validated report.
See also: [Operations](operations.md).
## Report template rendering fails
Symptom: Scriptorium output is available, but the report fails while building
the final Markdown document.
Likely cause: The selected report template or the render context is
incompatible with the generated or collected data.
Diagnostic: Inspect the metadata, generated-text result, and render-context
artifacts for the run. Note the template or missing-field fragment in the
error rather than relying on a complete error string.
Safe fix: Correct the template or its supported inputs in source control, test
the change, and create a new report. Do not alter the saved context merely to
make one historical run render.
See also: [Operations](operations.md).
## A report fails after artifacts are saved
Symptom: A generation command reports an error after showing a run ID, such as
an error writing the managed report, copying `--out`, saving metadata, or
notifying Distributor.
Likely cause: A local filesystem permission or path problem, an unavailable
destination for `--out`, or a later report-delivery failure occurred after
earlier steps succeeded.
Diagnostic: Run `weatherreporter inspect metadata RUN_ID` and check the exact
path and operation named in the error. For an `--out` failure, verify only the
specified destination directory and filename.
Safe fix: Repair access to that exact path or disable the optional delivery
step only when appropriate, then generate a new report. Keep the existing
managed artifacts untouched.
See also: [Operations](operations.md) and [Distributor integration](integrations/distributor/pkg-upload.md).
## A batch has partial report failures
Symptom: `run morning` or `run evening` returns nonzero and reports both
succeeded and failed report items.
Likely cause: A report-level collection, generation, rendering, or local
output failure affected one or more planned reports; the remaining reports
continue independently.
Diagnostic: Read the per-report status lines, then inspect the run ID for each
failed item with `weatherreporter inspect metadata RUN_ID`.
Safe fix: Correct the specific failure and rerun the batch or affected report.
Do not delete successful reports simply because another item failed.
See also: [Batch commands](cli.md#commands-and-usage) and [Operations](operations.md).
## A batch upload is skipped
Symptom: The batch result says Distributor notification was skipped because
one or more reports failed.
Likely cause: Batch notification intentionally runs only after every planned
report succeeds.
Diagnostic: Review the failed report items and their metadata; a skipped batch
notification is expected while any item is failed.
Safe fix: Resolve the report failures and rerun the batch. Do not upload a
partial bundle by manually reusing batch artifacts.
See also: [Batch commands](cli.md#commands-and-usage) and [Operations](operations.md).
## Distributor notification fails
Symptom: A completed report or otherwise successful batch reports a Distributor
error, including a rejected upload, source or idempotency conflict, or service
unavailability.
Likely cause: Distributor rejected the request identity or bundle, required
credentials are unavailable, or the remote service cannot be reached.
Diagnostic: Inspect the report metadata or batch result for the notification
artifact and the error fragment. Verify the configured Distributor endpoint and
request identity without exposing credentials.
Safe fix: Resolve the reported remote conflict, configuration, or availability
issue and create a new report or rerun the batch. Do not modify recorded bundle
or idempotency artifacts to force an upload.
See also: [Configuration](config.md), [Distributor integration](integrations/distributor/pkg-upload.md), and [Operations](operations.md).
## Secrets cannot be loaded
Symptom: Startup reports `read secrets directory`, `secret file`, or a token
environment-variable error before the affected service can be used.
Likely cause: The configured secrets directory cannot be read, contains a
non-regular file, or does not supply the environment variable required by an
enabled integration.
Diagnostic: Check the configured secrets directory path, ownership, and that
each intended secret is a regular file. Confirm the variable name from
configuration only; never print or paste its value.
Safe fix: Correct permissions, file type, or the missing secret file, then
retry. Keep secret values out of commands, logs, tickets, and artifacts.
See also: [Configuration](config.md) and [Operations](operations.md).
## A run ID or saved state cannot be found
Symptom: An inspection command reports that metadata for a run ID was not
found, or a report cannot use a prior snapshot.
Likely cause: The run ID is wrong, the configured workspace is different from
the one that created the run, or no compatible prior snapshot exists.
Diagnostic: Use `weatherreporter inspect reports` to list available reports in
the current workspace, then copy the run ID from that output. Confirm the
workspace configuration before retrying a prior-snapshot operation.
Safe fix: Use an existing run ID and its original workspace, or generate a new
compatible report when no prior snapshot is available. Do not fabricate state
files or run IDs.
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
## Workspace paths cannot be read or written
Symptom: Startup or report persistence reports a workspace-path, permission,
or "must be relative to workspace root" error.
Likely cause: A configured artifact directory escapes the workspace, or the
current user lacks access to the specific workspace location.
Diagnostic: Check the named configuration path against the configured workspace
root and inspect ownership and permissions of that exact directory.
Safe fix: Set the path to a location within the workspace or repair access to
the named directory, then rerun. Do not remove the workspace or broadly relax
permissions.
See also: [Configuration](config.md) and [Operations](operations.md).

View File

@@ -14,6 +14,9 @@ location:
secrets: secrets:
directory: "" directory: ""
output:
directory: /var/lib/weatherreporter/reports
notify: notify:
distributor: distributor:
enabled: false enabled: false
@@ -35,17 +38,10 @@ missing_source:
sources: sources:
alerts: none alerts: none
scriptorium: promptkit:
binary: scriptorium
timeout: 2m timeout: 2m
local:
workspace: concurrency_limit: 1
root: workspace
snapshots_dir: snapshots
reports_dir: reports
data_packages_dir: data-packages
preflight_dir: preflight
notifications_dir: notifications
dayparts: dayparts:
- name: overnight - name: overnight
@@ -64,12 +60,6 @@ dayparts:
start: "17:00" start: "17:00"
end: "24:00" end: "24:00"
recent_change:
temperature_degrees: 5
precip_probability_points: 20
wind_gust_miles_per_hour: 10
precip_timing_shift_minutes: 120
reports: reports:
daily: daily:
distributor: distributor:

View File

@@ -0,0 +1,4 @@
id: weather-light
endpoint: http://127.0.0.1:11434/v1
model: weather-local
timeout_seconds: 180

10
go.mod
View File

@@ -4,4 +4,12 @@ go 1.26
require gopkg.in/yaml.v3 v3.0.1 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.5.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 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8= gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
gitea.maximumdirect.net/eric/promptkit v0.5.0 h1:jnpazLyyNhWrB2xzwwtUkNUfktkTdkENTwuSPnKiYrc=
gitea.maximumdirect.net/eric/promptkit v0.5.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 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 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 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/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 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 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 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= 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 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,319 @@
// 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(), "."),
promptkit.WithFallbackProfileFS(promptassets.ProfileFS(), "."),
}
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.Inline(string(append([]byte(nil), request.DataPackage...)))},
})
if err != nil {
return nil, classifyError(err)
}
defer prepared.Discard()
details := prepared.Details()
preparation, debug := preparationValues(details, 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.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, 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,
}
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, 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,
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,548 @@
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", "2.0.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.0.0" || inspection.DefaultProfileID != "weather-balanced" {
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 TestEmbeddedProfilesAreAvailableToProductionAndTestAdapters(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
for _, want := range []struct {
id string
backend string
model string
}{
{"weather-light", "openrouter", "deepseek/deepseek-v4-flash"},
{"weather-balanced", "openrouter", "~google/gemini-flash-latest"},
{"weather-deep", "openrouter", "~anthropic/claude-sonnet-latest"},
} {
t.Run(want.id, func(t *testing.T) {
assertProfile(t, adapter, want.id, want.backend, want.model)
})
}
testAdapter, err := newAdapterForTest(Config{}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest() error = %v", err)
}
assertProfile(t, testAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
}
func TestConfiguredProfilesOverrideEmbeddedFallbacks(t *testing.T) {
file := writeProfileFile(t, `id: weather-light
endpoint: https://local-file.example/v1
model: file-light
`)
fileAdapter, err := New(Config{ProfileFile: file})
if err != nil {
t.Fatalf("New(profile file) error = %v", err)
}
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
directory := testProfileDirectory(t, `id: weather-light
backend: local
model: directory-light
`)
directoryAdapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
if err != nil {
t.Fatalf("New(profile directory) error = %v", err)
}
assertProfile(t, directoryAdapter, "weather-light", promptkit.BackendLocal, "directory-light")
}
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
adapter, err := New(Config{ProfileFile: filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml")})
if err != nil {
t.Fatalf("New() error = %v", err)
}
assertProfile(t, adapter, "weather-light", "", "weather-local")
}
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: other-profile
backend: openrouter
model: other-model
`)})
if err != nil {
t.Fatalf("New(absent profile) error = %v", err)
}
assertProfile(t, absentAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: weather-light
backend: openrouter
`)})
if err != nil {
t.Fatalf("New(malformed profile) error = %v", err)
}
if _, err := malformedAdapter.InspectProfile(context.Background(), "weather-light"); err == nil {
t.Fatal("InspectProfile() error = nil, want malformed configured profile error")
}
}
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
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.BackendID != "openrouter" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
explicit, err := newAdapter(Config{}, promptkit.WithProfiles(promptkit.Profile{
ID: "weather-light",
Endpoint: "https://explicit.example/v1",
Model: "explicit-light",
}))
if err != nil {
t.Fatalf("newAdapter(explicit profile) error = %v", err)
}
assertProfile(t, explicit, "weather-light", "", "explicit-light")
}
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.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 {
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 TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
client := &fakeClient{response: hourlyValidResponse()}
adapter, err := newAdapter(Config{}, promptkit.WithLLMClient(client))
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
request := promptexec.ExecuteRequest{
PromptID: "weather.hourly_generated_text",
PromptVersion: "2.0.0",
ProfileID: "weather-light",
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
}
var preparation promptexec.Preparation
prepared := false
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
if client.callCount() != 0 {
t.Fatal("provider was called before preparation completed")
}
preparation = value
prepared = true
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !prepared || preparation.ProfileID != "weather-light" || preparation.BackendID != "openrouter" || preparation.ModelName != "deepseek/deepseek-v4-flash" {
t.Fatalf("preparation = %#v", preparation)
}
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "openrouter" || result.ModelName != "deepseek/deepseek-v4-flash" || result.Validation.Status != promptexec.ValidationPassed {
t.Fatalf("execution = %#v", result)
}
if client.callCount() != 1 || client.request().Target.Model != "deepseek/deepseek-v4-flash" {
t.Fatalf("provider calls/request = %d/%#v", client.callCount(), client.request())
}
}
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 != "" || 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 assertProfile(t *testing.T, adapter *Adapter, id string, backend string, model string) {
t.Helper()
profile, err := adapter.InspectProfile(context.Background(), id)
if err != nil {
t.Fatalf("InspectProfile(%q) error = %v", id, err)
}
if profile.ProfileID != id || profile.BackendID != backend || profile.ModelName != model {
t.Fatalf("profile = %#v, want %q with backend/model %q/%q", profile, id, backend, model)
}
}
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 writeProfileFile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}
func testExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.daily_generated_text",
PromptVersion: "2.0.0",
ProfileID: "test-profile",
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
}
}
func validResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}
func hourlyValidResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{failedPrompt: generationDefinitionForPrompt("weather.tomorrow_generated_text").PromptID}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
t.Fatalf("report results = %#v", result.Reports)
}
if data, readErr := os.ReadFile(result.Reports[0].OutputPath); readErr != nil || len(data) == 0 {
t.Fatalf("successful output = %q, error = %v", data, readErr)
}
}
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || notifier.batchCalls != 1 || result.Notification == nil || result.Notification.Status != "succeeded" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if len(notifier.batchRequest.Files) < 2 || len(notifier.batchRequest.IncludedReports) != 2 {
t.Fatalf("batch notification = %#v", notifier.batchRequest)
}
if result.Reports[0].OutputPath == result.Reports[1].OutputPath {
t.Fatalf("batch reports share output path %q", result.Reports[0].OutputPath)
}
for _, file := range notifier.batchRequest.Files {
if filepath.Dir(file.SourcePath) != outputDir || file.BundlePath == "" {
t.Fatalf("notification file = %#v", file)
}
if _, statErr := os.Stat(file.SourcePath); statErr != nil {
t.Fatalf("notification source %q: %v", file.SourcePath, statErr)
}
}
}
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
tests := []struct {
name string
directory func(t *testing.T, workingDir string) string
wantDir func(t *testing.T, workingDir string, configuredDir string) string
}{
{
name: "working directory default",
directory: func(_ *testing.T, _ string) string {
return ""
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return workingDir
},
},
{
name: "absolute directory",
directory: func(t *testing.T, _ string) string {
return filepath.Join(t.TempDir(), "reports")
},
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
return configuredDir
},
},
{
name: "relative directory",
directory: func(_ *testing.T, _ string) string {
return "configured/../reports"
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return filepath.Join(workingDir, "reports")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workingDir := t.TempDir()
configuredDir := tt.directory(t, workingDir)
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredDir
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
wantDir := tt.wantDir(t, workingDir, configuredDir)
if err != nil || result == nil || result.Succeeded != len(result.Reports) || notifier.batchCalls != 1 {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
for _, item := range result.Reports {
if filepath.Dir(item.OutputPath) != wantDir {
t.Fatalf("report output %q, want directory %q", item.OutputPath, wantDir)
}
}
for _, file := range notifier.batchRequest.Files {
if filepath.Dir(file.SourcePath) != wantDir {
t.Fatalf("notification source %q, want directory %q", file.SourcePath, wantDir)
}
}
})
}
}
func TestRunBatchDetailedExplicitOutputDirectoryIgnoresConfiguredDirectory(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
explicitDir := t.TempDir()
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: explicitDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{},
})
if err != nil || result == nil || result.Succeeded != len(result.Reports) {
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
}
for _, item := range result.Reports {
if filepath.Dir(item.OutputPath) != explicitDir {
t.Fatalf("report output %q, want directory %q", item.OutputPath, explicitDir)
}
}
}
func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil {
t.Fatal(err)
}
todayPath := filepath.Join(outputDir, "today.md")
const previousReport = "previous report"
if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
executor := &generationExecutor{}
promptInspectedBeforeCollection := false
collector := &generationCollector{
bundle: &bundle,
beforeRun: func() {
promptInspectedBeforeCollection = executor.promptInspections > 0
},
}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: collector, Executor: executor, Notifier: &generationNotifier{},
})
if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called {
t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called)
}
if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport {
t.Fatalf("earlier output = %q, error = %v", data, readErr)
}
if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() {
t.Fatalf("blocked output info/error = %#v/%v", info, statErr)
}
}
func TestRunBatchDetailedRetainsReportCountsWhenNotificationFails(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
notifier := &generationNotifier{batchErr: errors.New("distributor unavailable")}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if err != nil || result == nil || result.Total != len(result.Reports) || result.Succeeded != len(result.Reports) || result.Failed != 0 || result.Notification == nil || result.Notification.Status != "failed" {
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
}
for _, item := range result.Reports {
if item.Status != "succeeded" || item.OutputPath == "" {
t.Fatalf("report result = %#v", item)
}
if _, statErr := os.Stat(item.OutputPath); statErr != nil {
t.Fatalf("published output %q: %v", item.OutputPath, statErr)
}
}
}
func TestRunBatchReturnsNotificationFailureWithoutReportFailureWording(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
err := RunBatch(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{batchErr: errors.New("distributor unavailable")},
})
var batchErr BatchError
if !errors.As(err, &batchErr) || batchErr.Result == nil || batchErr.Result.Failed != 0 || batchErr.Result.Notification == nil || batchErr.Result.Notification.Status != "failed" || !strings.Contains(err.Error(), "notification failed") || strings.Contains(err.Error(), "reports failed") {
t.Fatalf("RunBatch() error/result = %v/%#v", err, batchErr.Result)
}
}
func generationDistributorConfig() config.Config {
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
return cfg
}

View File

@@ -3,12 +3,12 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath"
"time" "time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -46,39 +46,31 @@ func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch) return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
} }
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) { func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
if !cfg.Notify.Distributor.Enabled { if !cfg.Notify.Distributor.Enabled {
return nil, nil return nil
} }
if !cfg.Notify.Distributor.Batch.Enabled { if !cfg.Notify.Distributor.Batch.Enabled {
return nil, nil return nil
} }
if result == nil { if result == nil {
return nil, fmt.Errorf("batch result is required") return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
} }
if result.Failed > 0 { if result.Failed > 0 {
return &BatchNotificationResult{ return &BatchNotificationResult{
Status: "skipped", Status: "skipped",
Reason: "one or more reports failed", Reason: "one or more reports failed",
}, nil }
} }
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned) req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
if err != nil { if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err) return failedBatchNotificationResult(batchNotificationRequest{}, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
} }
batchNotifier, err := resolveBatchNotifier(cfg, notifier) batchNotifier, err := resolveBatchNotifier(cfg, notifier)
if err != nil { if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err) return failedBatchNotificationResult(req, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(req, path, err), err
} }
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req) notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
@@ -86,18 +78,13 @@ func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID
if notifyErr != nil { if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr) wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
} }
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr) batchResult := batchNotificationResult(req, notification)
if saveErr != nil {
return nil, saveErr
}
batchResult := batchNotificationResult(req, notification, path)
if wrappedErr != nil { if wrappedErr != nil {
batchResult.Status = "failed" batchResult.Status = "failed"
batchResult.Error = wrappedErr.Error() batchResult.Error = wrappedErr.Error()
return batchResult, wrappedErr return batchResult
} }
return batchResult, nil return batchResult
} }
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) { func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
@@ -153,15 +140,15 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
if item.ReportID != plannedReport.Resolved.Definition.ID { if item.ReportID != plannedReport.Resolved.Definition.ID {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID) return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
} }
if item.ReportPath == "" { if item.OutputPath == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID) return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID)
} }
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName) values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath))
if err != nil { if err != nil {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err)
} }
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values) bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values)
if err != nil { if err != nil {
return batchNotificationRequest{}, err return batchNotificationRequest{}, err
} }
@@ -169,18 +156,18 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
included := BatchNotificationReport{ included := BatchNotificationReport{
ReportID: item.ReportID, ReportID: item.ReportID,
RunID: item.RunID, RunID: item.RunID,
SourcePath: item.ReportPath, SourcePath: item.OutputPath,
BundlePaths: append([]string(nil), bundlePaths...), BundlePaths: append([]string(nil), bundlePaths...),
} }
for _, bundlePath := range bundlePaths { for _, bundlePath := range bundlePaths {
file := batchNotificationFile{ file := batchNotificationFile{
ReportID: item.ReportID, ReportID: item.ReportID,
RunID: item.RunID, RunID: item.RunID,
SourcePath: item.ReportPath, SourcePath: item.OutputPath,
BundlePath: bundlePath, BundlePath: bundlePath,
} }
if previous, ok := seenBundlePaths[bundlePath]; ok { if previous, ok := seenBundlePaths[bundlePath]; ok {
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath) return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath)
} }
seenBundlePaths[bundlePath] = file seenBundlePaths[bundlePath] = file
req.Files = append(req.Files, file) req.Files = append(req.Files, file)
@@ -225,13 +212,12 @@ func batchDistributorUploadRequest(req batchNotificationRequest) distributoradap
} }
} }
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult { func batchNotificationResult(req batchNotificationRequest, result *NotificationResult) *BatchNotificationResult {
notification := &BatchNotificationResult{ notification := &BatchNotificationResult{
Status: "unknown", Status: "unknown",
PipelineID: req.PipelineID, PipelineID: req.PipelineID,
BundleID: req.BundleID, BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey, IdempotencyKey: req.IdempotencyKey,
Path: path,
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...), IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
} }
if result != nil { if result != nil {
@@ -256,8 +242,8 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
return notification return notification
} }
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult { func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil, path) notification := batchNotificationResult(req, nil)
notification.Status = "failed" notification.Status = "failed"
if err != nil { if err != nil {
notification.Error = err.Error() notification.Error = err.Error()
@@ -265,78 +251,6 @@ func failedBatchNotificationResult(req batchNotificationRequest, path string, er
return notification return notification
} }
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
if store == nil {
return "", fmt.Errorf("state store is required")
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return "", fmt.Errorf("load batch notification timezone: %w", err)
}
artifact := state.BatchDistributorNotificationArtifact{
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
Batch: string(batch),
BatchRunID: runID,
AttemptedAt: time.Now(),
Endpoint: cfg.Notify.Distributor.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
BundleCreated: req.CreatedAt,
Reports: batchNotificationReportArtifacts(req.IncludedReports),
Status: "attempted",
}
if result != nil {
artifact.Status = result.Status
artifact.Upload = &state.DistributorUploadResult{
RunID: result.RunID,
Status: result.UploadStatus,
}
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
artifact.RunStatus = &state.DistributorRunStatus{
RunID: result.RunID,
PipelineID: result.PipelineID,
Status: result.Status,
AcceptedAt: result.AcceptedAt,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Report: append([]byte(nil), result.Report...),
Error: result.Error,
}
}
artifact.StatusError = result.StatusError
}
if notifyErr != nil {
artifact.Status = "failed"
artifact.Error = notifyErr.Error()
}
if artifact.Status == "" {
artifact.Status = "unknown"
}
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
Batch: string(batch),
BatchRunID: runID,
StartedAt: startedAt,
Location: location,
}, artifact)
}
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
if len(reports) == 0 {
return nil
}
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
for _, item := range reports {
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.SourcePath,
BundlePaths: append([]string(nil), item.BundlePaths...),
})
}
return artifacts
}
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) { func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt) values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
if err != nil { if err != nil {

View File

@@ -12,7 +12,7 @@ import (
type plannedBatchReport struct { type plannedBatchReport struct {
Resolved report.Resolved Resolved report.Resolved
OutputCopyName string OutputPath string
} }
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) { func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
@@ -36,16 +36,16 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
var planned []plannedBatchReport var planned []plannedBatchReport
switch batch { switch batch {
case report.Morning: case report.Morning:
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "") planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
case report.Evening: case report.Evening:
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -60,8 +60,7 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
for _, date := range eligibleDailyDates(hourly, now, location) { for _, date := range eligibleDailyDates(hourly, now, location) {
dailyReq := resolveReq dailyReq := resolveReq
dailyReq.Date = date dailyReq.Date = date
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md" planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq)
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -69,15 +68,12 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
return planned, nil return planned, nil
} }
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) { func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) {
resolved, err := registry.Resolve(id, req) resolved, err := registry.Resolve(id, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return append(planned, plannedBatchReport{ return append(planned, plannedBatchReport{Resolved: resolved}), nil
Resolved: resolved,
OutputCopyName: outputCopyName,
}), nil
} }
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time { func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {

View File

@@ -55,20 +55,7 @@ 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") 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) { func TestPlanBatchRunUsesResolvedOutputNames(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") location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
@@ -81,11 +68,19 @@ func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
if len(daily) != 1 { if len(daily) != 1 {
t.Fatalf("daily reports = %#v, want one Daily report", daily) t.Fatalf("daily reports = %#v, want one Daily report", daily)
} }
if daily[0].OutputCopyName != "daily-2026-05-31.md" { outputName, err := daily[0].Resolved.OutputName()
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName) if err != nil {
t.Fatalf("OutputName() error = %v", err)
} }
if planned[0].OutputCopyName != "" { if outputName != "daily-2026-05-31.md" {
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName) t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
}
outputName, err = planned[0].Resolved.OutputName()
if err != nil {
t.Fatalf("OutputName() error = %v", err)
}
if outputName != "tomorrow.md" {
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
} }
} }

View File

@@ -0,0 +1,395 @@
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"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/weatherdata"
)
type generationCollector struct {
bundle *weatherdata.Bundle
err error
called bool
beforeRun func()
}
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
if c.beforeRun != nil {
c.beforeRun()
}
c.called = true
return &collect.Result{Bundle: c.bundle}, c.err
}
type generationExecutor struct {
called bool
promptInspections int
inspectErr error
executeErr error
cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus
rawOutput []byte
failedPrompt string
}
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
e.promptInspections++
if e.inspectErr != nil {
return promptexec.PromptInspection{}, e.inspectErr
}
definition := generationDefinitionForPrompt(id)
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "fixture", 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"}}, nil
}
func (*generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
}
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := 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: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
return nil, err
}
e.called = true
if e.executeErr != nil {
return nil, e.executeErr
}
status := e.validation
if status == "" {
status = promptexec.ValidationPassed
}
if e.failedPrompt == req.PromptID {
status = promptexec.ValidationFailed
}
rawOutput := e.rawOutput
if rawOutput == nil {
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."}`)
}
if e.cancelBeforeReturn != nil {
e.cancelBeforeReturn()
}
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", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil
}
func generationDefinitionForPrompt(promptID string) report.Definition {
for _, definition := range report.DefaultRegistry().All() {
if definition.PromptID == promptID {
return definition
}
}
panic("unknown fixture prompt " + promptID)
}
func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
bundle := generationBundle(t)
executor := &generationExecutor{}
workingDir := t.TempDir()
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: executor})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if !executor.called || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" {
t.Fatalf("result = %#v", result)
}
if result.LLMDebugPath != "" {
t.Fatalf("unexpected debug output = %q", result.LLMDebugPath)
}
if _, err := os.Stat(filepath.Join(workingDir, "workspace")); !os.IsNotExist(err) {
t.Fatalf("unexpected default state directory: %v", err)
}
data, err := os.ReadFile(result.OutputPath)
if err != nil || len(data) == 0 {
t.Fatalf("output = %q, error = %v", data, err)
}
}
func TestGenerateDetailedUsesConfiguredOutputDirectory(t *testing.T) {
tests := []struct {
name string
directory func(t *testing.T, workingDir string) string
wantDir func(t *testing.T, workingDir string, configuredDir string) string
}{
{
name: "absolute directory",
directory: func(t *testing.T, _ string) string {
return filepath.Join(t.TempDir(), "reports")
},
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
return configuredDir
},
},
{
name: "relative directory",
directory: func(_ *testing.T, _ string) string {
return "configured/../reports"
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return filepath.Join(workingDir, "reports")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workingDir := t.TempDir()
configuredDir := tt.directory(t, workingDir)
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredDir
bundle := generationBundle(t)
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
wantPath := filepath.Join(tt.wantDir(t, workingDir, configuredDir), "daily-2026-05-29.md")
if err != nil || result == nil || result.OutputPath != wantPath || notifier.request.ReportPath != wantPath {
t.Fatalf("GenerateDetailed() result/error/notification = %#v/%v/%#v", result, err, notifier.request)
}
if info, statErr := os.Stat(filepath.Dir(wantPath)); statErr != nil || !info.IsDir() {
t.Fatalf("configured output directory info/error = %#v/%v", info, statErr)
}
if _, statErr := os.Stat(wantPath); statErr != nil {
t.Fatalf("output %q: %v", wantPath, statErr)
}
})
}
}
func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
explicitPath := filepath.Join(t.TempDir(), "explicit.md")
cfg := generationConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: explicitPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
if err != nil || result == nil || result.OutputPath != explicitPath {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}
if _, statErr := os.Stat(explicitPath); statErr != nil {
t.Fatalf("explicit output %q: %v", explicitPath, statErr)
}
}
func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
if data, readErr := os.ReadFile(configuredPath); readErr != nil || string(data) != "not a directory" {
t.Fatalf("configured path = %q, error = %v", data, readErr)
}
}
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
collectionErr := errors.New("weather source unavailable")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), Collector: &generationCollector{err: collectionErr}, Executor: &generationExecutor{},
})
if !errors.Is(err, collectionErr) {
t.Fatalf("GenerateDetailed() error = %v, want %v", err, collectionErr)
}
if result == nil || result.ReportID != report.Daily || result.RunID == "" || result.ProfileID != "fixture" || result.BackendID != "fixture" || result.ModelName != "fixture-model" || result.OutputPath != "" {
t.Fatalf("result = %#v", result)
}
}
func TestGenerateDetailedInspectsPromptBeforeCollectingWeather(t *testing.T) {
cfg := generationConfig()
inspectionErr := errors.New("profile is invalid")
collector := &generationCollector{bundle: generationBundlePointer(t)}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: &generationExecutor{inspectErr: inspectionErr}})
if !errors.Is(err, inspectionErr) || collector.called || result == nil {
t.Fatalf("GenerateDetailed() result/error/collector-called = %#v/%v/%t", result, err, collector.called)
}
}
func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
for _, scenario := range []struct {
name string
executor generationExecutor
}{
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
} {
t.Run(scenario.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
t.Fatal(err)
}
bundle := generationBundle(t)
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
data, readErr := os.ReadFile(outputPath)
if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
})
}
}
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
defer cancel()
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
bundle := generationBundle(t)
outputPath := filepath.Join(t.TempDir(), "daily.md")
notifier := &generationNotifier{err: errors.New("distributor unavailable")}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier})
if err == nil || result == nil || result.OutputPath != outputPath || notifier.request.ReportPath != outputPath || len(notifier.request.BundlePaths) == 0 {
t.Fatalf("GenerateDetailed() result/error/request = %#v/%v/%#v", result, err, notifier.request)
}
if data, readErr := os.ReadFile(outputPath); readErr != nil || len(data) == 0 {
t.Fatalf("published output = %q, error = %v", data, readErr)
}
}
func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
bundle := generationBundle(t)
outputPath := filepath.Join(t.TempDir(), "daily.md")
if err := os.Mkdir(outputPath, 0o700); err != nil {
t.Fatal(err)
}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}})
info, statErr := os.Stat(outputPath)
if err == nil || result == nil || statErr != nil || !info.IsDir() {
t.Fatalf("GenerateDetailed() result/error/output-info = %#v/%v/%#v (%v)", result, err, info, statErr)
}
}
func generationConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
return cfg
}
func generationBundlePointer(t *testing.T) *weatherdata.Bundle {
bundle := generationBundle(t)
return &bundle
}
type generationNotifier struct {
err error
batchErr error
calls int
request NotificationRequest
batchRequest batchNotificationRequest
batchCalls int
}
func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) {
n.calls++
n.request = request
if n.err != nil {
return nil, n.err
}
return &NotificationResult{Status: "succeeded"}, nil
}
func (n *generationNotifier) NotifyBatch(_ context.Context, request batchNotificationRequest) (*NotificationResult, error) {
n.batchCalls++
n.batchRequest = request
for _, file := range request.Files {
if _, err := os.Stat(file.SourcePath); err != nil {
return nil, err
}
}
return &NotificationResult{Status: "succeeded", PipelineID: request.PipelineID, BundleID: request.BundleID}, n.batchErr
}
func generationBundle(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)
}
return bundle
}
func generationTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339, value)
return parsed
}
var _ promptexec.Executor = (*generationExecutor)(nil)
var _ Collector = (*generationCollector)(nil)
var _ Notifier = (*generationNotifier)(nil)
var _ = report.Daily

View File

@@ -1,126 +0,0 @@
package app
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type InspectReportsRequest struct {
Config config.Config
Limit int
}
type InspectRunRequest struct {
Config config.Config
RunID string
}
type SourceInspection struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
return store.ListReports(ctx, req.Limit)
}
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
inspection, err := inspectRun(ctx, req)
return inspection.metadata, err
}
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return module.Snapshot{}, err
}
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
}
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return promptinput.Package{}, err
}
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
}
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return nil, err
}
resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil {
return nil, err
}
return inspection.store.FindPriorSnapshot(ctx, resolved)
}
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return SourceInspection{}, err
}
metadata := inspection.metadata
return SourceInspection{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
SourceLocation: metadata.SourceLocation,
Sources: metadata.Sources,
Warnings: metadata.SourceWarnings,
}, nil
}
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil {
return report.Resolved{}, err
}
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return report.Resolved{}, err
}
if !metadata.ValidPeriod.IsValid() {
return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID)
}
return report.Resolved{
Definition: definition,
GeneratedAt: metadata.GeneratedAt,
Timezone: location.String(),
ValidPeriod: metadata.ValidPeriod,
}, nil
}

163
internal/app/output.go Normal file
View File

@@ -0,0 +1,163 @@
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
outputName, err := planned.Resolved.OutputName()
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error {
for index := range plannedReports {
outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index])
if err != nil {
return err
}
plannedReports[index].OutputPath = outputPath
}
return nil
}
func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) {
outputName, err := resolved.OutputName()
if err != nil {
return "", err
}
if override != "" {
return resolveOutputPath(workingDir, override, outputName)
}
outputDir, err := resolveOutputDir(workingDir, configuredDir)
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) {
directory := configuredDir
if override != "" {
directory = override
}
return resolveOutputDir(workingDir, directory)
}
func resolveOutputDir(workingDir, override string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
if override == "" {
return workingDir, nil
}
if strings.TrimSpace(override) == "" {
return "", fmt.Errorf("output directory is required")
}
directory := override
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
directory = filepath.Clean(directory)
if err := preflightOutputDirectory(directory); err != nil {
return "", err
}
return directory, nil
}
func preflightOutputDirectory(directory string) error {
info, err := os.Stat(directory)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("output directory %q is not a directory", directory)
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect output directory %q: %w", directory, err)
}
// A missing directory is valid, but os.Stat also reports ErrNotExist for a
// dangling symlink. Walk to the first existing component so invalid links
// fail preflight instead of being discovered only during publication.
for component := directory; ; component = filepath.Dir(component) {
componentInfo, componentErr := os.Lstat(component)
if componentErr == nil {
if componentInfo.Mode()&os.ModeSymlink != 0 {
targetInfo, targetErr := os.Stat(component)
if targetErr != nil {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr)
}
if !targetInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !componentInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !os.IsNotExist(componentErr) {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr)
}
if filepath.Dir(component) == component {
return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory)
}
}
}
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
path := override
if path == "" {
path = defaultName
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
if !filepath.IsAbs(path) {
path = filepath.Join(workingDir, path)
}
return validateOutputPath(path)
}
func validateWorkingDir(workingDir string) (string, error) {
if strings.TrimSpace(workingDir) == "" {
return "", fmt.Errorf("working directory is required")
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func validateOutputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("final output path %q must be absolute", path)
}
if filepath.Dir(path) == path {
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
return "", fmt.Errorf("final output path %q is a directory", path)
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
}
return path, nil
}

View File

@@ -0,0 +1,38 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
workingDir := t.TempDir()
dangling := filepath.Join(workingDir, "dangling")
if err := os.Symlink(filepath.Join(workingDir, "missing"), dangling); err != nil {
t.Fatal(err)
}
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
t.Run(filepath.Base(directory), func(t *testing.T) {
if _, err := resolveOutputDir(workingDir, directory); err == nil {
t.Fatalf("resolveOutputDir(%q) error = nil, want dangling symlink error", directory)
}
})
}
}
func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
workingDir := t.TempDir()
target := t.TempDir()
link := filepath.Join(workingDir, "linked")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
directory := filepath.Join(link, "reports")
got, err := resolveOutputDir(workingDir, directory)
if err != nil || got != directory {
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
}
}

View File

@@ -0,0 +1,214 @@
package app
import (
"context"
"errors"
"fmt"
"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/promptdebug"
"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/weatherdata"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
DebugWriter *promptdebug.PromptDebugWriter
Result *ReportResult
noNotify bool
}
type promptReportWorkflow struct {
ctx context.Context
req promptReportRequest
result *ReportResult
briefingMetadata briefing.Metadata
reportFacts ReportFacts
moduleSnapshot module.Snapshot
dataPackage []byte
handler generatedtext.Handler
debugRef promptdebug.PromptDebugRef
callbackFailed 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, err := workflow.executePrompt()
if err != nil {
if workflow.callbackFailed {
return workflow.result, err
}
return workflow.result, workflow.reportError("execute prompt", classifiedPromptError("prompt execution failed", err))
}
if execution == nil {
return workflow.result, workflow.reportError("execute prompt", promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil))
}
workflow.result.ValidationStatus = execution.Validation.Status
if err := workflow.writeExecutionDebug(*execution); err != nil {
return workflow.result, err
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil))
}
if execution.Validation.Status == promptexec.ValidationFailed {
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil))
}
return workflow.renderAndPublish(execution.RawOutput)
}
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, error) {
if req.Collection.Bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
result := req.Result
if result == nil {
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
}
return &promptReportWorkflow{
ctx: ctx, req: req,
result: result,
}, nil
}
func initialReportResult(req GenerateRequest, resolved report.Resolved, inspection PromptInspectionResult) *ReportResult {
metadata := resolved.Metadata()
return &ReportResult{
ReportID: resolved.Definition.ID, ReportName: resolved.Definition.Name,
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
RunID: metadata.RunID, GeneratedAt: metadata.GeneratedAt, Timezone: req.Config.WeatherAPI.Timezone,
ValidPeriod: metadata.ValidPeriod,
ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName,
}
}
func (w *promptReportWorkflow) buildInputs() error {
var err error
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
if err != nil {
return w.reportError("build report facts", err)
}
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
if err != nil {
return w.reportError("build module snapshot", err)
}
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
w.result.SourceWarnings = append([]weatherdata.SourceWarning(nil), w.briefingMetadata.SourceWarnings...)
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(w.briefingMetadata), Modules: w.moduleSnapshot})
if err != nil {
return w.reportError("build data package", err)
}
w.dataPackage, err = promptinput.MarshalYAML(dataPackage)
if err != nil {
return w.reportError("marshal data package", err)
}
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
if err != nil {
return w.reportError("lookup generated text catalog", err)
}
w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID}
return nil
}
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
captureDebug := w.req.DebugWriter != nil && w.req.DebugWriter.Enabled()
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.dataPackage, CaptureDebug: captureDebug}, w.writePreparationDebug)
}
func (w *promptReportWorkflow) writePreparationDebug(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
w.result.ProfileID, w.result.BackendID, w.result.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
if w.req.DebugWriter == nil {
return nil
}
path, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
if err != nil {
w.callbackFailed = true
return promptDebugWriteError(err)
}
w.result.LLMDebugPath = path
return nil
}
func (w *promptReportWorkflow) writeExecutionDebug(execution promptexec.Execution) error {
if w.req.DebugWriter == nil {
return nil
}
path, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
if err != nil {
return w.reportError("write prompt debug", promptDebugWriteError(err))
}
if path != "" {
w.result.LLMDebugPath = path
}
return nil
}
func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, error) {
generatedText, _, err := w.handler.Validate(raw)
if err != nil {
return w.result, w.reportError("validate generated text", err)
}
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
if err != nil {
return w.result, w.reportError("build render context", err)
}
rendered, err := w.handler.Render(renderContext)
if err != nil {
return w.result, w.reportError("render template", err)
}
if err := publicationContextError(w.ctx); err != nil {
return w.result, w.reportError("publish report", err)
}
if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil {
return w.result, err
}
w.result.OutputPath = w.req.OutputPath
if w.req.noNotify {
return w.result, nil
}
notification, err := notifyReport(w.ctx, w.req.Config, w.req.Resolved, w.result.OutputPath, w.result.RunID, w.result.GeneratedAt, w.req.Notifier)
w.result.Notification = notification
if err != nil {
return w.result, err
}
return w.result, nil
}
func (w *promptReportWorkflow) reportError(operation string, err error) error {
return generatedReportError(w.req.Resolved, w.result.RunID, operation, err)
}
func classifiedPromptError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.Generation, operation, err)
}
func publicationContextError(ctx context.Context) error {
if err := ctx.Err(); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
}
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
}
return nil
}
func promptDebugWriteError(err error) error {
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
}

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,201 @@
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"},
}
}
func logicalPromptInspection(definition report.Definition) promptexec.PromptInspection {
inspection := validPromptInspection(definition)
if definition.ID == report.Hourly {
inspection.DefaultProfileID = "weather-light"
} else {
inspection.DefaultProfileID = "weather-balanced"
}
return inspection
}

View File

@@ -0,0 +1,73 @@
package app_test
import (
"context"
"os"
"path/filepath"
"testing"
"time"
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing.T) {
lookupEnv := func(string) (string, bool) { return "test-key", true }
inspect := func(t *testing.T, adapter *promptkitadapter.Adapter, id report.ID, profile string, wantID string, wantBackend string, wantModel string) {
t.Helper()
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
Resolved: resolvedPromptProfile(t, id),
Executor: adapter,
Promptkit: config.PromptkitConfig{Profile: profile},
LookupEnv: lookupEnv,
})
if err != nil {
t.Fatalf("InspectPromptExecution() error = %v", err)
}
if result.ProfileID != wantID || result.BackendID != wantBackend || result.ModelName != wantModel {
t.Fatalf("inspection = %#v, want profile/backend/model %q/%q/%q", result, wantID, wantBackend, wantModel)
}
}
embedded, err := promptkitadapter.New(promptkitadapter.Config{})
if err != nil {
t.Fatalf("New(embedded) error = %v", err)
}
inspect(t, embedded, report.Hourly, "", "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
inspect(t, embedded, report.Daily, "", "weather-balanced", "openrouter", "~google/gemini-flash-latest")
inspect(t, embedded, report.Daily, "weather-deep", "weather-deep", "openrouter", "~anthropic/claude-sonnet-latest")
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
endpoint: https://local.example/v1
model: local-weather
`)})
if err != nil {
t.Fatalf("New(override) error = %v", err)
}
inspect(t, override, report.Hourly, "", "weather-light", "", "local-weather")
}
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {
t.Helper()
now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
request := report.ResolveRequest{Now: now, Location: time.UTC}
if id == report.Daily {
request.Date = now
}
resolved, err := report.DefaultRegistry().Resolve(id, request)
if err != nil {
t.Fatalf("Resolve(%q) error = %v", id, err)
}
return resolved
}
func writeProfileFile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}

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

@@ -155,10 +155,10 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) { func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() 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}) _, 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) t.Fatalf("BuildModule() error = %v, want incompatible report", err)
} }
} }
@@ -227,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) { func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() 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}) _, 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) t.Fatalf("BuildModule() error = %v, want incompatible report", err)
} }
} }

View File

@@ -603,7 +603,7 @@ func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) { func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry() 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) { t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id) ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})

View File

@@ -265,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
ID: module.Metadata, 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 { if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err) 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) { t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}}) err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) { if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {

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,313 +0,0 @@
// Package changes compares structured module snapshots.
package changes
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type Thresholds struct {
TemperatureDegrees float64
PrecipProbabilityPoints int
WindGustMilesPerHour int
PrecipTimingShiftMinutes int
}
type Change struct {
Type string `json:"type"`
Message string `json:"message"`
Previous string `json:"previous,omitempty"`
Current string `json:"current,omitempty"`
}
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
if err != nil {
return nil, fmt.Errorf("previous daily summary: %w", err)
}
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
if err != nil {
return nil, fmt.Errorf("current daily summary: %w", err)
}
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
}
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
}
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
if err != nil {
return nil, err
}
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
if err != nil {
return nil, err
}
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
if err != nil {
return nil, err
}
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
if err != nil {
return nil, err
}
var changes []Change
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
if previousHasTiming && currentHasTiming {
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
}
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
sortChanges(changes)
return changes, nil
}
type dailySummaryStanza struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
}
type daypartSummaryStanza struct {
Date string `json:"date,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
}
type precipTimingStanza struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
}
type alertDigestStanza struct {
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
}
type alertSummaryStanza struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
}
type indicators struct {
Snow bool
Ice bool
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
if err != nil {
return value, err
}
if !ok {
return value, fmt.Errorf("stanza %q is required", name)
}
return value, nil
}
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
if previous == nil || current == nil {
return nil
}
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
return nil
}
return []Change{{
Type: "temperature_shift",
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
Previous: fmt.Sprintf("%d", *previous),
Current: fmt.Sprintf("%d", *current),
}}
}
func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil {
return nil
}
previousCategory := precipitationCategory(float64(*previous))
currentCategory := precipitationCategory(float64(*current))
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
changeType := prefix + "precip_probability_change"
return []Change{{
Type: changeType,
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
}}
}
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
return nil
}
previousTime, previousOK := parseClock(previous)
currentTime, currentOK := parseClock(current)
if !previousOK || !currentOK {
return nil
}
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
return nil
}
return []Change{{
Type: prefix + "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
Previous: previous,
Current: current,
}}
}
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
return []Change{{
Type: prefix + "wind_gust_change",
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
Previous: fmt.Sprintf("%d mph", *previous),
Current: fmt.Sprintf("%d mph", *current),
}}
}
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
previousSet := alertSet(previous)
currentSet := alertSet(current)
var changes []Change
for event := range currentSet {
if _, ok := previousSet[event]; !ok {
changes = append(changes, Change{Type: "alert_added", Message: fmt.Sprintf("Alert added: %s.", event), Current: event})
}
}
for event := range previousSet {
if _, ok := currentSet[event]; !ok {
changes = append(changes, Change{Type: "alert_removed", Message: fmt.Sprintf("Alert removed: %s.", event), Previous: event})
}
}
sortChanges(changes)
return changes
}
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
var changes []Change
for _, item := range []struct {
name string
previous bool
current bool
}{
{name: "snow", previous: previous.Snow, current: current.Snow},
{name: "ice", previous: previous.Ice, current: current.Ice},
} {
if item.previous == item.current {
continue
}
changeType := prefix + item.name + "_risk_change"
if item.current {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
} else {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is no longer present.", item.name), Previous: "present"})
}
}
return changes
}
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
out := indicators{}
for _, daypart := range dayparts {
out.Snow = out.Snow || daypart.Snow
out.Ice = out.Ice || daypart.Ice
}
return out
}
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
out := map[string]struct{}{}
for _, alert := range alerts {
event := alert.Event
if event == "" {
event = alert.Headline
}
if event != "" {
out[event] = struct{}{}
}
}
return out
}
func precipitationCategory(value float64) string {
switch {
case value >= 70:
return "high"
case value >= 50:
return "likely"
case value >= 20:
return "possible"
default:
return "low"
}
}
func differenceAtLeast(previous float64, current float64, threshold float64) bool {
if threshold <= 0 {
return previous != current
}
return math.Abs(current-previous) >= threshold
}
func sortChanges(items []Change) {
sort.SliceStable(items, func(i, j int) bool {
if items[i].Type == items[j].Type {
return items[i].Message < items[j].Message
}
return items[i].Type < items[j].Type
})
}
func parseClock(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func parseTempRange(value string) (*int, *int) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parts := strings.Split(value, "-")
if len(parts) == 1 {
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
return &parsed, &parsed
}
return nil, nil
}
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
if minErr != nil || maxErr != nil {
return nil, nil
}
return &minValue, &maxValue
}

View File

@@ -1,138 +0,0 @@
package changes
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if len(changes) != 0 {
t.Fatalf("changes = %#v, want none", changes)
}
}
func TestCompareDailyTemperatureThreshold(t *testing.T) {
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "temperature_shift") != 2 {
t.Fatalf("changes = %#v, want low and high temperature changes", changes)
}
}
func TestCompareDailyPrecipTimingShift(t *testing.T) {
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "precip_timing_shift") != 1 {
t.Fatalf("changes = %#v, want timing shift", changes)
}
}
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "alert_added") != 1 || countType(changes, "alert_removed") != 1 {
t.Fatalf("changes = %#v, want one alert added and one removed", changes)
}
}
func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "snow_risk_change") != 1 {
t.Fatalf("changes = %#v, want snow risk change", changes)
}
}
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
_, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
if err == nil {
t.Fatal("CompareDaily() error = nil, want missing stanza error")
}
if !strings.Contains(err.Error(), "derived_daily_summary") {
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
}
}
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
t.Helper()
relevant := make([]alertSummaryStanza, 0, len(alerts))
for _, alert := range alerts {
relevant = append(relevant, alertSummaryStanza{Event: alert})
}
return snapshot(t,
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
Date: "2026-05-29",
HighTempF: &high,
LowTempF: &low,
DailyPrecipitationProbability: &precip,
}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {
Date: "2026-05-29",
PeriodBegins: "2026-05-29 at 6:00 AM",
PeriodEnds: "2026-05-29 at 10:00 AM",
TempRangeF: "60-70",
Snow: snow,
},
}},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
)
}
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func testThresholds() Thresholds {
return Thresholds{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
}
}
func countType(changes []Change, changeType string) int {
var count int
for _, change := range changes {
if change.Type == changeType {
count++
}
}
return count
}

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

@@ -33,21 +33,11 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
return return
} }
for _, item := range result.Reports { for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" { if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields) _, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
continue continue
} }
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields) _, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
} }
if result.Notification != nil { if result.Notification != nil {
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status) _, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
@@ -63,9 +53,6 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
if result.Notification.BundleID != "" { if result.Notification.BundleID != "" {
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID) _, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
} }
if result.Notification.Path != "" {
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
}
if result.Notification.Error != "" { if result.Notification.Error != "" {
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error) _, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
} }

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const ( const (
@@ -25,16 +26,15 @@ type generateSummary struct {
Status string `json:"status"` Status string `json:"status"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"` OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"` LLMDebugPath string `json:"llmDebugPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"` PromptVersion string `json:"promptVersion"`
PreflightPath string `json:"preflightPath,omitempty"` Timezone string `json:"timezone"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` ProfileID string `json:"profileId,omitempty"`
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"` BackendID string `json:"backendId,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"` ModelName string `json:"modelName,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"` ValidationStatus string `json:"validationStatus,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"` Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -73,24 +73,20 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
return summary return summary
} }
metadata := result.Metadata summary.ReportID = result.ReportID
summary.ReportID = metadata.ReportID summary.ReportName = result.ReportName
summary.ReportName = reportName(metadata.ReportID) summary.PromptID = result.PromptID
summary.PromptID = metadata.PromptID summary.PromptVersion = result.PromptVersion
summary.RunID = metadata.RunID summary.RunID = result.RunID
summary.Status = summaryStatusSucceeded summary.Status = summaryStatusSucceeded
summary.GeneratedAt = metadata.GeneratedAt summary.GeneratedAt = result.GeneratedAt
summary.ValidPeriod = metadata.ValidPeriod summary.ValidPeriod = result.ValidPeriod
summary.ReportPath = result.ReportPath summary.Timezone = result.Timezone
summary.ProfileID, summary.BackendID, summary.ModelName = result.ProfileID, result.BackendID, result.ModelName
summary.SourceWarnings = append([]weatherdata.SourceWarning(nil), result.SourceWarnings...)
summary.ValidationStatus = string(result.ValidationStatus)
summary.OutputPath = result.OutputPath summary.OutputPath = result.OutputPath
summary.MetadataPath = result.MetadataPath summary.LLMDebugPath = result.LLMDebugPath
summary.DataPackagePath = result.DataPackagePath
summary.PreflightPath = result.PreflightPath
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
summary.GeneratedTextPath = result.GeneratedTextPath
summary.RenderContextPath = result.RenderContextPath
summary.NotificationPath = result.NotificationPath
summary.Notification = newGenerateNotificationSummary(result.Notification) summary.Notification = newGenerateNotificationSummary(result.Notification)
if err != nil { if err != nil {
summary.Status = summaryStatusFailed summary.Status = summaryStatusFailed

View File

@@ -2,230 +2,30 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { func TestGenerateSummaryUsesActiveResultFields(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
acceptedAt := generatedAt.Add(time.Minute) summary := newGenerateSummary(&app.ReportResult{ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", RunID: "run-123", GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)}, ProfileID: "weather-balanced", BackendID: "openrouter", ModelName: "model", SourceWarnings: []weatherdata.SourceWarning{{Source: "alerts", Message: "source unavailable"}}, ValidationStatus: promptexec.ValidationPassed, OutputPath: "/reports/daily.md"}, nil)
startedAt := acceptedAt.Add(time.Minute) if summary.OutputPath == "" || summary.ProfileID == "" || summary.ValidationStatus != string(promptexec.ValidationPassed) || len(summary.SourceWarnings) != 1 {
finishedAt := startedAt.Add(time.Minute) t.Fatalf("summary = %#v", summary)
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",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
RunID: "20260529T133000Z_hourly",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
Notification: &app.NotificationResult{
Status: "succeeded",
UploadStatus: "accepted",
RunID: "distributor-run",
PipelineID: "weatherreporter.hourly",
BundleID: "weatherreporter.home.hourly",
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
AcceptedAt: acceptedAt,
StartedAt: &startedAt,
FinishedAt: &finishedAt,
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
},
}
summary := newGenerateSummary(result, nil)
if summary.Command != "generate" || summary.Status != "succeeded" {
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
}
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 == "" {
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) {
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
} }
data, err := json.Marshal(summary) data, err := json.Marshal(summary)
if err != nil { if err != nil {
t.Fatalf("Marshal() error = %v", err) t.Fatalf("Marshal() error = %v", err)
} }
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") { for _, forbidden := range []string{"reportPath", "metadataPath", "dataPackagePath", "preparationPath", "executionPath", "generatedTextRawPath", "generatedTextPath", "renderContextPath"} {
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data)) if strings.Contains(string(data), forbidden) {
} t.Fatalf("summary includes %q: %s", forbidden, data)
}
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(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",
Metadata: state.Metadata{
ReportID: report.ThreeDay,
PromptID: "weather.three_day_outlook",
RunID: "20260529T133000Z_three_day",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
}
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.Notification != nil || summary.NotificationPath != "" {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
}
data, err := json.Marshal(summary)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
if strings.Contains(string(data), omitted) {
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
} }
} }
} }
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",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
RunID: "20260529T133000Z_hourly",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
}
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
summary := newGenerateSummary(result, err)
if summary.Status != "failed" || summary.Error != err.Error() {
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
}
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
}
}
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
finishedAt := startedAt.Add(2 * time.Minute)
tests := []struct {
name string
result *app.BatchResult
wantStatus string
wantError string
}{
{
name: "success",
result: &app.BatchResult{
Batch: app.BatchMorning,
StartedAt: startedAt,
FinishedAt: finishedAt,
Total: 1,
Succeeded: 1,
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
},
wantStatus: "succeeded",
},
{
name: "report failure",
result: &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 1,
Failed: 1,
Reports: []app.BatchReportResult{
{ReportID: report.Today, Status: "succeeded"},
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
},
},
wantStatus: "failed",
wantError: "batch morning failed: 1 of 2 reports failed",
},
{
name: "skipped notification",
result: &app.BatchResult{
Batch: app.BatchEvening,
Total: 2,
Succeeded: 1,
Failed: 1,
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
Notification: &app.BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
},
},
wantStatus: "failed",
wantError: "batch evening failed: 1 of 2 reports failed",
},
{
name: "failed notification",
result: &app.BatchResult{
Batch: app.BatchEvening,
Total: 1,
Succeeded: 1,
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch evening: upload rejected",
},
},
wantStatus: "failed",
wantError: "batch evening notification failed: notify batch evening: upload rejected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := newBatchSummary(tt.result)
if summary.Command != "run" || summary.Status != tt.wantStatus {
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
}
if summary.Error != tt.wantError {
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
}
if len(summary.Reports) != len(tt.result.Reports) {
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
}
})
}
}
func testSummaryPeriod(start time.Time) timeutil.Period {
return timeutil.Period{
Start: start,
End: start.Add(6 * time.Hour),
}
}

View File

@@ -5,8 +5,11 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app" "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/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -16,34 +19,32 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter --version
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
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 inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
Options: Options:
-h, --help Show this help message. -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. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy where supported by the generate command. --out PATH Write the generated Markdown report to PATH.
--out-dir PATH Write extra Markdown report copies for run commands. --llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands.
--quiet Suppress successful generate and run output. --quiet Suppress successful generate and run output.
` `
type Runner struct { type Runner struct {
Clock timeutil.Clock Clock timeutil.Clock
ExecutorFactory ExecutorFactory
Version string
WorkingDir string
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
} }
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
@@ -58,6 +59,17 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
_, err := fmt.Fprint(stdout, helpText) _, err := fmt.Fprint(stdout, helpText)
return err 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] { switch args[0] {
case "generate": case "generate":
@@ -78,7 +90,11 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
if err != nil { if err != nil {
return err return err
} }
result, err := app.RunBatchDetailed(ctx, req) runBatchDetailed := r.runBatchDetailed
if runBatchDetailed == nil {
runBatchDetailed = app.RunBatchDetailed
}
result, err := runBatchDetailed(ctx, req)
if result != nil { if result != nil {
summary := newBatchSummary(result) summary := newBatchSummary(result)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) { if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
@@ -91,8 +107,6 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
} }
} }
return err return err
case "inspect":
return r.runInspect(ctx, args[1:], stdout)
default: default:
return fmt.Errorf("unknown command %q", args[0]) return fmt.Errorf("unknown command %q", args[0])
} }
@@ -104,89 +118,13 @@ type commonOptions struct {
Timezone string Timezone string
Output string Output string
OutputDir string OutputDir string
LLMDebugDir string
Quiet bool Quiet bool
} }
type generateOptions struct { type generateOptions struct {
commonOptions commonOptions
Date string Date string
Start string
End string
}
type inspectOptions struct {
ConfigPath string
Limit int
RunID string
}
type inspectRunCommand struct {
Name string
Inspect func(context.Context, app.InspectRunRequest) (any, error)
}
var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req)
}},
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectModules(ctx, req)
}},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req)
}},
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectPriorSnapshot(ctx, req)
}},
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectSources(ctx, req)
}},
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("inspect requires a command")
}
command := args[0]
switch command {
case "reports":
opts, err := parseInspectReportsFlags(args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit})
if err != nil {
return err
}
return writeJSON(stdout, records)
default:
for _, candidate := range inspectRunCommands {
if candidate.Name == command {
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
}
}
return fmt.Errorf("unknown inspect command %q", command)
}
}
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
opts, err := parseInspectRunFlags(command.Name, args)
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, value)
} }
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
@@ -218,16 +156,32 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil { if err != nil {
return app.GenerateRequest{}, commonOptions{}, err 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) location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil { if err != nil {
return app.GenerateRequest{}, commonOptions{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
workingDir, err := r.workingDir()
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
outputPath, err := resolveOutputOverride(workingDir, opts.Output)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req := app.GenerateRequest{ req := app.GenerateRequest{
Config: cfg, Config: cfg,
Report: reportKind, Report: reportKind,
OutputPath: opts.Output, WorkingDir: workingDir,
OutputPath: outputPath,
LLMDebugDir: opts.LLMDebugDir,
Now: r.Clock.Now(), Now: r.Clock.Now(),
Executor: executor,
} }
switch reportKind { switch reportKind {
@@ -248,19 +202,6 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
return app.GenerateRequest{}, commonOptions{}, err 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 return req, opts.commonOptions, nil
@@ -294,7 +235,19 @@ func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions
if err != nil { if err != nil {
return app.BatchRequest{}, commonOptions{}, err 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
}
workingDir, err := r.workingDir()
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir)
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), WorkingDir: workingDir, OutputDir: outputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
} }
func resolveRun(args []string) (app.BatchRequest, error) { func resolveRun(args []string) (app.BatchRequest, error) {
@@ -310,10 +263,6 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
if report == app.ReportDaily || report == app.ReportToday { if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") 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 { if err := fs.Parse(args); err != nil {
return generateOptions{}, err return generateOptions{}, err
} }
@@ -328,7 +277,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
opts := commonOptions{} opts := commonOptions{}
addCommonFlags(fs, &opts, false) addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory") fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return commonOptions{}, err return commonOptions{}, err
@@ -339,44 +288,37 @@ func parseRunFlags(args []string) (commonOptions, error) {
return opts, nil return opts, nil
} }
func parseInspectReportsFlags(args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{Limit: 20}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() > 0 {
return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
if opts.Limit < 0 {
return inspectOptions{}, fmt.Errorf("limit must be zero or greater")
}
return opts, nil
}
func parseInspectRunFlags(command string, args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() != 1 {
return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command)
}
opts.RunID = fs.Arg(0)
return opts, nil
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
if includeOutput { if includeOutput {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
} }
} }
func (r Runner) workingDir() (string, error) {
workingDir := r.WorkingDir
if workingDir == "" {
var err error
workingDir, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func resolveOutputOverride(workingDir, value string) (string, error) {
if value == "" {
return "", nil
}
if !filepath.IsAbs(value) {
value = filepath.Join(workingDir, value)
}
return filepath.Clean(value), nil
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,208 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"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("weather_api:\n base_url: https://weather.api.example.com/\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)
}
})
}
}
func TestResolveGenerateActionUsesInjectedWorkingDirectoryForOutputOverrides(t *testing.T) {
workingDir := t.TempDir()
configPath := filepath.Join(t.TempDir(), "config.yml")
const configuredDirectory = "configured/../reports"
if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: "+configuredDirectory+"\n"), 0o600); err != nil {
t.Fatal(err)
}
absoluteOutput := filepath.Join(t.TempDir(), "daily.md")
for _, scenario := range []struct {
name string
out string
want string
}{
{name: "default", want: ""},
{name: "relative", out: "reports/daily.md", want: filepath.Join(workingDir, "reports", "daily.md")},
{name: "absolute", out: absoluteOutput, want: absoluteOutput},
} {
t.Run(scenario.name, func(t *testing.T) {
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return &factoryExecutor{}, nil
},
}
args := []string{"daily", "--date", "2026-05-29", "--config", configPath}
if scenario.out != "" {
args = append(args, "--out", scenario.out)
}
req, _, err := runner.resolveGenerateAction(args)
if err != nil || req.WorkingDir != workingDir || req.OutputPath != scenario.want || req.Config.Output.Directory != configuredDirectory {
t.Fatalf("resolveGenerateAction() request/error = %#v/%v", req, err)
}
})
}
}
func TestResolveActionsPreserveConfiguredOutputDirectoryWithoutAnOverride(t *testing.T) {
workingDir := t.TempDir()
absoluteDirectory := filepath.Join(t.TempDir(), "reports")
for _, configuredDirectory := range []string{"configured/../reports", absoluteDirectory} {
t.Run(configuredDirectory, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yml")
contents := "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: " + strconv.Quote(configuredDirectory) + "\n"
if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return &factoryExecutor{}, nil
},
}
generateReq, _, generateErr := runner.resolveGenerateAction([]string{"daily", "--date", "2026-05-29", "--config", configPath})
if generateErr != nil || generateReq.Config.Output.Directory != configuredDirectory || generateReq.OutputPath != "" {
t.Fatalf("resolveGenerateAction() request/error = %#v/%v", generateReq, generateErr)
}
batchReq, _, batchErr := runner.resolveRunAction([]string{"morning", "--config", configPath})
if batchErr != nil || batchReq.Config.Output.Directory != configuredDirectory || batchReq.OutputDir != "" {
t.Fatalf("resolveRunAction() request/error = %#v/%v", batchReq, batchErr)
}
})
}
}
func TestResolveRunActionUsesInjectedWorkingDirectoryForOutputOverrides(t *testing.T) {
workingDir := t.TempDir()
configPath := filepath.Join(t.TempDir(), "config.yml")
const configuredDirectory = "configured/../reports"
if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: "+configuredDirectory+"\n"), 0o600); err != nil {
t.Fatal(err)
}
absoluteOutput := filepath.Join(t.TempDir(), "reports")
for _, scenario := range []struct {
name string
out string
want string
}{
{name: "relative", out: "reports/../published", want: filepath.Join(workingDir, "published")},
{name: "absolute", out: absoluteOutput, want: absoluteOutput},
} {
t.Run(scenario.name, func(t *testing.T) {
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return &factoryExecutor{}, nil
},
}
req, _, err := runner.resolveRunAction([]string{"morning", "--config", configPath, "--out-dir", scenario.out})
if err != nil || req.WorkingDir != workingDir || req.OutputDir != scenario.want || req.Config.Output.Directory != configuredDirectory {
t.Fatalf("resolveRunAction() request/error = %#v/%v", req, err)
}
})
}
}
func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil {
t.Fatal(err)
}
result := &app.BatchResult{
Batch: app.BatchMorning, Total: 2, Succeeded: 2,
Reports: []app.BatchReportResult{
{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"},
{ReportID: "tomorrow", Status: "succeeded", OutputPath: "/reports/tomorrow.md"},
},
Notification: &app.BatchNotificationResult{Status: "failed", Error: "distributor unavailable"},
}
var stdout, stderr bytes.Buffer
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return &factoryExecutor{}, nil
},
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
return result, nil
},
}
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
var batchErr app.BatchError
if !errors.As(err, &batchErr) || !strings.Contains(err.Error(), "notification failed") {
t.Fatalf("Run() error = %v", err)
}
var summary batchSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v", err)
}
if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 2 || summary.Failed != 0 || summary.Notification == nil || summary.Notification.Status != "failed" {
t.Fatalf("summary = %#v", summary)
}
if !strings.Contains(stderr.String(), `batchNotification status="failed"`) {
t.Fatalf("stdout/stderr = %q/%q", stdout.String(), stderr.String())
}
for _, field := range []string{"notificationStatus", "notificationRunId", "notificationPipelineId", "notificationError"} {
if strings.Contains(stdout.String(), field) || strings.Contains(stderr.String(), field) {
t.Fatalf("stdout/stderr includes removed field %q: %q/%q", field, stdout.String(), stderr.String())
}
}
}
func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)
if err == nil || err.Error() != `unknown command "inspect"` {
t.Fatalf("Run(inspect) error = %v", err)
}
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil {
t.Fatalf("Run(--help) error = %v", err)
}
if strings.Contains(stdout.String(), "inspect") {
t.Fatalf("help contains removed inspect command:\n%s", stdout.String())
}
}

View File

@@ -25,12 +25,11 @@ type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"` WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"` Location LocationConfig `yaml:"location"`
Secrets SecretsConfig `yaml:"secrets"` Secrets SecretsConfig `yaml:"secrets"`
Output OutputConfig `yaml:"output"`
Notify NotifyConfig `yaml:"notify"` Notify NotifyConfig `yaml:"notify"`
MissingSource MissingSourceConfig `yaml:"missing_source"` MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Promptkit PromptkitConfig `yaml:"promptkit"`
Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"` Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
Reports map[string]ReportConfig `yaml:"reports"` Reports map[string]ReportConfig `yaml:"reports"`
} }
@@ -53,6 +52,10 @@ type SecretsConfig struct {
Directory string `yaml:"directory"` Directory string `yaml:"directory"`
} }
type OutputConfig struct {
Directory string `yaml:"directory"`
}
type NotifyConfig struct { type NotifyConfig struct {
Distributor DistributorNotifyConfig `yaml:"distributor"` Distributor DistributorNotifyConfig `yaml:"distributor"`
} }
@@ -81,21 +84,17 @@ type MissingSourceConfig struct {
Sources map[string]MissingSourcePolicy `yaml:"sources"` Sources map[string]MissingSourcePolicy `yaml:"sources"`
} }
type ScriptoriumConfig struct { type PromptkitConfig struct {
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Profile string `yaml:"profile"` Profile string `yaml:"profile"`
ProfileFile string `yaml:"profile_file"`
ProfileDir string `yaml:"profile_dir"`
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
ExtraArgs []string `yaml:"extra_args"` Local PromptkitLocalConfig `yaml:"local"`
} }
type WorkspaceConfig struct { type PromptkitLocalConfig struct {
Root string `yaml:"root"` Endpoint string `yaml:"endpoint"`
SnapshotsDir string `yaml:"snapshots_dir"` ConcurrencyLimit int `yaml:"concurrency_limit"`
ReportsDir string `yaml:"reports_dir"`
DataPackagesDir string `yaml:"data_packages_dir"`
PreflightDir string `yaml:"preflight_dir"`
NotificationsDir string `yaml:"notifications_dir"`
} }
type DaypartConfig struct { type DaypartConfig struct {
@@ -104,13 +103,6 @@ type DaypartConfig struct {
End string `yaml:"end"` End string `yaml:"end"`
} }
type RecentChangeConfig struct {
TemperatureDegrees float64 `yaml:"temperature_degrees"`
PrecipProbabilityPoints int `yaml:"precip_probability_points"`
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
}
type ReportConfig struct { type ReportConfig struct {
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
Distributor ReportDistributorConfig `yaml:"distributor"` Distributor ReportDistributorConfig `yaml:"distributor"`

View File

@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -37,6 +38,9 @@ func TestDefaults(t *testing.T) {
if cfg.Secrets.Directory != "" { if cfg.Secrets.Directory != "" {
t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory) t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory)
} }
if cfg.Output.Directory != "" {
t.Fatalf("Output.Directory = %q, want empty", cfg.Output.Directory)
}
if cfg.Notify.Distributor.Enabled { if cfg.Notify.Distributor.Enabled {
t.Fatalf("Notify.Distributor.Enabled = true, want false") t.Fatalf("Notify.Distributor.Enabled = true, want false")
} }
@@ -78,6 +82,120 @@ func TestDefaults(t *testing.T) {
} }
} }
func TestOutputDirectoryLoading(t *testing.T) {
tests := []struct {
name string
yaml string
wantValue string
wantErr string
}{
{
name: "omitted",
yaml: "{}\n",
wantValue: "",
},
{
name: "explicit empty",
yaml: `
output:
directory: ""
`,
wantValue: "",
},
{
name: "absolute path",
yaml: `
output:
directory: /var/lib/weatherreporter/reports
`,
wantValue: "/var/lib/weatherreporter/reports",
},
{
name: "relative path",
yaml: `
output:
directory: reports/../published
`,
wantValue: "reports/../published",
},
{
name: "whitespace only",
yaml: `
output:
directory: " \t "
`,
wantErr: "output.directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := LoadFile(writeConfig(t, tt.yaml))
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadFile() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Output.Directory != tt.wantValue {
t.Fatalf("Output.Directory = %q, want %q", cfg.Output.Directory, tt.wantValue)
}
})
}
}
func TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs(t *testing.T) {
tests := []struct {
name string
directory string
wantErr string
}{
{name: "empty"},
{name: "absolute path", directory: "/var/lib/weatherreporter/reports"},
{name: "relative path", directory: "reports/../published"},
{name: "whitespace only", directory: " \t ", wantErr: "output.directory"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
yaml := "output:\n directory: " + strconv.Quote(tt.directory) + "\n"
_, loadErr := LoadFile(writeConfig(t, yaml))
cfg := Defaults()
cfg.Output.Directory = tt.directory
validateErr := Validate(cfg)
if (loadErr == nil) != (validateErr == nil) {
t.Fatalf("LoadFile() error = %v, Validate() error = %v", loadErr, validateErr)
}
if tt.wantErr != "" {
if loadErr == nil || !strings.Contains(loadErr.Error(), tt.wantErr) {
t.Fatalf("LoadFile() error = %v, want %q", loadErr, tt.wantErr)
}
if validateErr == nil || !strings.Contains(validateErr.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", validateErr, tt.wantErr)
}
}
})
}
}
func TestOutputDirectoryRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
output:
location: reports
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want strict decoding error")
}
if !strings.Contains(err.Error(), "field location not found") {
t.Fatalf("LoadFile() error = %q, want output field rejection", err.Error())
}
}
func TestLoadExampleConfig(t *testing.T) { func TestLoadExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml")) cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml"))
if err != nil { if err != nil {
@@ -96,6 +214,9 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location) t.Fatalf("Location = %#v, want example location", cfg.Location)
} }
if cfg.Output.Directory != "/var/lib/weatherreporter/reports" {
t.Fatalf("Output.Directory = %q, want maintained example value", cfg.Output.Directory)
}
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" { if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
} }
@@ -144,20 +265,35 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
if cfg.WeatherAPI.Units != "us" { if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units) t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units)
} }
if cfg.Scriptorium.Binary != "scriptorium" { if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary) t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
}
if cfg.Workspace.Root != "workspace" {
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
}
if cfg.Workspace.NotificationsDir != "notifications" {
t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir)
} }
if cfg.Location.Name != "Brentwood" { if cfg.Location.Name != "Brentwood" {
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name) t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
} }
} }
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 TestLoadRejectsRemovedRecentChangeConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "recent_change:\n temperature_degrees: 5\n"))
if err == nil || !strings.Contains(err.Error(), "recent_change") {
t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err)
}
}
func TestLoadRejectsRemovedWorkspaceConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "workspace:\n root: workspace\n"))
if err == nil || !strings.Contains(err.Error(), "workspace") {
t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err)
}
}
func TestLoadReportModuleOverrides(t *testing.T) { func TestLoadReportModuleOverrides(t *testing.T) {
path := writeConfig(t, ` path := writeConfig(t, `
reports: reports:
@@ -275,39 +411,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) { func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, ` path := writeConfig(t, `
reports: reports:
@@ -375,35 +478,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) { func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults() cfg := Defaults()
rawOptions := map[string]any{ rawOptions := map[string]any{
@@ -683,21 +757,6 @@ reports:
`, `,
wantErr: `unknown report distributor field "paths"`, 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", name: "UnknownTemplateVariable",
yaml: ` yaml: `
@@ -754,17 +813,6 @@ reports:
`, `,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`, 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", name: "EmptyOverrideList",
yaml: ` yaml: `
@@ -790,23 +838,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) { func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := ` yaml := `
reports: reports:
@@ -869,29 +900,6 @@ reports:
}, },
wantErr: "reports.moon", 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", name: "UnknownModule",
yaml: ` yaml: `
@@ -1389,38 +1397,37 @@ func TestDistributorTemplateRendering(t *testing.T) {
ValidEndTime: "0600", ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800", ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600", ValidEndStamp: "2026-06-08T0600",
StormID: "2026-06-07T1800-2026-06-08T0600",
BundleID: "weatherreporter.home.daily", 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 { if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err) 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) t.Fatalf("bundleID = %q, want rendered value", bundleID)
} }
values.BundleID = 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 { if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err) 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) 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 { if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err) 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) t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
} }
reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{ reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md", "{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", "{valid_start_date}/{artifact_group}/latest.md",
}, values) }, values)
if err != nil { if err != nil {
@@ -1428,7 +1435,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
} }
wantPaths := []string{ wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md", "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", "2026-06-07/daily/latest.md",
} }
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") { if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
@@ -1788,6 +1795,9 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
t.Cleanup(func() { t.Cleanup(func() {
_ = os.Chmod(path, 0o600) _ = os.Chmod(path, 0o600)
}) })
if _, err := os.ReadFile(path); err == nil {
t.Skip("test process can read files without permission bits")
}
}, },
wantErr: "read secret file", wantErr: "read secret file",
}, },

View File

@@ -21,6 +21,9 @@ func Defaults() Config {
Secrets: SecretsConfig{ Secrets: SecretsConfig{
Directory: "", Directory: "",
}, },
Output: OutputConfig{
Directory: "",
},
Notify: NotifyConfig{ Notify: NotifyConfig{
Distributor: DistributorNotifyConfig{ Distributor: DistributorNotifyConfig{
Enabled: false, Enabled: false,
@@ -43,17 +46,11 @@ func Defaults() Config {
Default: MissingSourceWarn, Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{}, Sources: map[string]MissingSourcePolicy{},
}, },
Scriptorium: ScriptoriumConfig{ Promptkit: PromptkitConfig{
Binary: "scriptorium",
Timeout: 2 * time.Minute, Timeout: 2 * time.Minute,
Local: PromptkitLocalConfig{
ConcurrencyLimit: 1,
}, },
Workspace: WorkspaceConfig{
Root: "workspace",
SnapshotsDir: "snapshots",
ReportsDir: "reports",
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
NotificationsDir: "notifications",
}, },
Dayparts: []DaypartConfig{ Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "overnight", Start: "00:00", End: "06:00"},
@@ -62,12 +59,6 @@ func Defaults() Config {
{Name: "afternoon", Start: "15:00", End: "17:00"}, {Name: "afternoon", Start: "15:00", End: "17:00"},
{Name: "evening", Start: "17:00", End: "24:00"}, {Name: "evening", Start: "17:00", End: "24:00"},
}, },
RecentChange: RecentChangeConfig{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
},
Reports: map[string]ReportConfig{}, Reports: map[string]ReportConfig{},
} }
} }

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -59,7 +60,12 @@ func mergeFile(cfg *Config, path string) error {
if err != nil { if err != nil {
return fmt.Errorf("read config %q: %w", path, err) return fmt.Errorf("read config %q: %w", path, err)
} }
if err := yaml.Unmarshal(data, cfg); err != nil { if err := rejectRetiredExecutionConfig(data); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(cfg); err != nil {
return fmt.Errorf("parse config %q: %w", path, err) return fmt.Errorf("parse config %q: %w", path, err)
} }
if cfg.MissingSource.Sources == nil { if cfg.MissingSource.Sources == nil {
@@ -70,3 +76,20 @@ func mergeFile(cfg *Config, path string) error {
} }
return nil 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 ValidEndTime string
ValidStartStamp string ValidStartStamp string
ValidEndStamp string ValidEndStamp string
StormID string
BundleID string BundleID string
} }
@@ -42,7 +41,6 @@ var distributorTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
} }
var distributorIdempotencyTemplateVariables = map[string]struct{}{ var distributorIdempotencyTemplateVariables = map[string]struct{}{
@@ -57,7 +55,6 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
"bundle_id": {}, "bundle_id": {},
} }
@@ -246,8 +243,6 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
return values.ValidStartStamp return values.ValidStartStamp
case "valid_end_stamp": case "valid_end_stamp":
return values.ValidEndStamp return values.ValidEndStamp
case "storm_id":
return values.StormID
case "bundle_id": case "bundle_id":
return values.BundleID return values.BundleID
default: default:

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