Compare commits
22 Commits
5e96790d85
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dbba36bf0 | |||
| f302581722 | |||
| cf82633ab7 | |||
| 8d8cdbf3c5 | |||
| a206979307 | |||
| a6515c0e56 | |||
| 41df5058ba | |||
| e1bc174ea9 | |||
| 34c395d7e5 | |||
| 870b54a4a0 | |||
| 25782447eb | |||
| b96f40e5ca | |||
| 2c68d0a85f | |||
| a6d11c01e8 | |||
| 06b26d5e88 | |||
| 9a17a8de93 | |||
| 6064af2295 | |||
| a52a6ed22a | |||
| b0b703eab4 | |||
| e4e824ed41 | |||
| d5fcbfd20c | |||
| 2e0fb65a8b |
@@ -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
|
||||||
|
|||||||
42
docs/cli.md
42
docs/cli.md
@@ -17,15 +17,13 @@ required Weather API endpoint.
|
|||||||
|
|
||||||
```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 reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
@@ -34,16 +32,19 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
|||||||
weatherreporter inspect sources [--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. |
|
||||||
| `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. |
|
||||||
| `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 accepts the common generate flags. |
|
||||||
| `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. 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. `--out-dir` writes extra Markdown copies; `--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, workspace artifacts, and
|
||||||
notification sequencing are described in the [operations guide](operations.md).
|
notification sequencing are described in the [operations guide](operations.md).
|
||||||
|
|
||||||
@@ -84,9 +85,8 @@ time, valid period, and status:
|
|||||||
```
|
```
|
||||||
|
|
||||||
When available, the summary also includes `reportPath`, `metadataPath`,
|
When available, the summary also includes `reportPath`, `metadataPath`,
|
||||||
`dataPackagePath`, and `preflightPath`. Generated-text reports additionally
|
`dataPackagePath`, `preparationPath`, `executionPath`, `generatedTextRawPath`,
|
||||||
include `generatedTextRawPath`, `generatedTextResultPath`,
|
`generatedTextPath`, `renderContextPath`, and `llmDebugPath`. `outputPath` is included only
|
||||||
`generatedTextPath`, and `renderContextPath`. `outputPath` is included only
|
|
||||||
when `--out` wrote an extra copy. Distributor notification, when attempted,
|
when `--out` wrote an extra copy. Distributor notification, when attempted,
|
||||||
adds `notificationPath` and may add a compact `notification` object.
|
adds `notificationPath` and may add a compact `notification` object.
|
||||||
|
|
||||||
@@ -113,10 +113,10 @@ batch=morning total=2 succeeded=2 failed=0
|
|||||||
| `--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 an extra Markdown report copy. |
|
||||||
|
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics outside the managed workspace. The path must be absolute. |
|
||||||
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
|
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
|
||||||
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
|
| `--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. |
|
| `--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
|
||||||
@@ -128,8 +128,8 @@ no Distributor-specific CLI flags. See the [configuration reference](config.md).
|
|||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
weatherreporter generate today --date 2026-05-29 --out ./today.md
|
weatherreporter generate today --date 2026-05-29 --out ./today.md
|
||||||
weatherreporter generate hourly --out ./hourly.md
|
weatherreporter generate hourly --out ./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
|
## Inspection Commands
|
||||||
@@ -152,6 +152,6 @@ weatherreporter inspect sources 20260529T100000.000000000Z_today
|
|||||||
| `inspect prior RUN_ID` | Prior comparable snapshot metadata, or `null` when none exists. |
|
| `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. |
|
| `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
|
Inspection is read-only: it does not collect weather data or invoke Promptkit.
|
||||||
`scriptorium`. See the [operations guide](operations.md) for artifact lifecycle
|
See the [operations guide](operations.md) for artifact lifecycle
|
||||||
and recovery.
|
and recovery.
|
||||||
|
|||||||
@@ -102,10 +102,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,9 +123,6 @@ 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.
|
artifact selection, and failure handling.
|
||||||
@@ -139,15 +135,23 @@ 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 explicit execution profile. Otherwise the prompt's declared default is used. |
|
||||||
| `config_path` | empty | Optional Scriptorium configuration path. |
|
| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. |
|
||||||
| `profile` | empty | Optional Scriptorium profile. |
|
| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. |
|
||||||
| `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`
|
### `workspace`
|
||||||
|
|
||||||
@@ -189,9 +193,8 @@ prior comparable module snapshot.
|
|||||||
`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:
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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 managed Markdown reports, and can upload completed
|
||||||
reports through Distributor. Start with the [README](../README.md) for product
|
reports 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.
|
||||||
@@ -27,11 +27,12 @@ boundaries and invariants.
|
|||||||
| 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. |
|
| 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. |
|
||||||
| 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. |
|
| Prompt execution, profiles, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
|
||||||
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
|
| 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. |
|
| Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. |
|
||||||
| Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
|
| Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
|
||||||
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
|
| 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
|
||||||
@@ -46,7 +47,7 @@ present before introducing a new package or abstraction.
|
|||||||
| `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` | Generation, batches, collection coordination, notification, and inspection orchestration. |
|
||||||
| `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`, `internal/changes` | Report registry, module contracts and values, and structured comparison. |
|
||||||
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
||||||
@@ -68,7 +69,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
|
||||||
|
|||||||
22
docs/integrations/promptkit.md
Normal file
22
docs/integrations/promptkit.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Promptkit Integration
|
||||||
|
|
||||||
|
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are
|
||||||
|
`weather.daily_generated_text`, `weather.today_generated_text`,
|
||||||
|
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version
|
||||||
|
`1.0.0`. Their prompt assets and generated-text JSON Schemas are embedded by
|
||||||
|
`internal/promptassets`.
|
||||||
|
|
||||||
|
Before collection, Weatherreporter inspects the exact prompt version, requires one required
|
||||||
|
`data_package` input with content type `application/yaml`, and requires the report's JSON
|
||||||
|
Schema output contract. It selects `promptkit.profile` when configured, otherwise the
|
||||||
|
prompt's declared default profile. Profiles that require a direct API key are unsupported; a
|
||||||
|
profile that reports `APIKeyEnv` requires a nonblank value in that environment variable.
|
||||||
|
|
||||||
|
Execution receives the already-persisted YAML package, prepares it once, and returns structured
|
||||||
|
JSON that Weatherreporter validates before rendering its own Markdown template. Preparation and
|
||||||
|
execution receipts are project-owned, safe provenance records. Content-rich diagnostics are
|
||||||
|
opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||||
|
|
||||||
|
Prompt/profile configuration is owned by the [configuration reference](../config.md). Adapter
|
||||||
|
construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
|
||||||
|
Durable metadata compatibility is described in [state internals](../internal/state.md).
|
||||||
@@ -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.
|
|
||||||
@@ -1,110 +1,48 @@
|
|||||||
# Application Orchestration Internals
|
# Application Orchestration Internals
|
||||||
|
|
||||||
`internal/app` composes top-level generation, batch, collection-save, and
|
`internal/app` owns top-level generation, batch, collection, inspection, and
|
||||||
inspection workflows after CLI parsing and configuration loading. It owns
|
notification ordering after the CLI has parsed arguments and loaded configuration.
|
||||||
workflow ordering, request composition, partial-result handling, and the
|
|
||||||
application-facing interfaces used for tests.
|
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Generation
|
||||||
|
|
||||||
The package accepts generate, resolved-report, batch, explicit-collection, and
|
`GenerateDetailed` resolves one of the four report definitions, initializes an
|
||||||
inspection requests. Generation and batch requests may supply collector,
|
optional debug root, and inspects the exact Promptkit prompt/profile before it
|
||||||
renderer, store, and notifier implementations for tests; production defaults
|
collects weather or writes managed state. It then builds facts and modules,
|
||||||
use the focused packages.
|
saves the YAML data package, persists preparation metadata from the executor
|
||||||
|
callback, executes the prepared prompt, saves execution provenance and raw
|
||||||
|
output, validates generated text, renders Markdown, and optionally copies or
|
||||||
|
notifies from the managed report.
|
||||||
|
|
||||||
A report result contains the module snapshot, prompt package, available
|
After a completed prompt run, each successfully written downstream artifact is
|
||||||
Scriptorium results, generated-text artifacts when used, report and metadata
|
atomically added to the execution record before the corresponding metadata
|
||||||
paths, prior snapshot, Recent Changes, and notification information. A batch
|
rewrite. Later failures therefore leave the original Promptkit outcome and its
|
||||||
result contains aggregate counts, per-report outcomes, and an optional batch
|
last durable set of reached paths inspectable.
|
||||||
notification. Inspection returns persisted values only.
|
|
||||||
|
|
||||||
Exact public command syntax, configuration fields, workspace layout, external
|
Failure results retain all safe paths reached so far. Validation rejection
|
||||||
protocols, and report definitions belong in [the CLI reference](../cli.md),
|
persists raw output and execution provenance but does not render a report.
|
||||||
[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` constructs a single debug writer and uses the request's
|
||||||
report using the configured registry and current time, and finally calls
|
single executor. Before collection it inspects Today, Tomorrow, and Daily for
|
||||||
`GenerateReport` with that explicit collection. It returns no result when
|
morning, or Tomorrow and Daily for evening, deduplicating effective profile
|
||||||
collection or resolution fails.
|
inspection. It then collects once, plans eligible Daily dates, and calls the
|
||||||
|
same prompt-generation core sequentially for each planned report. Per-report
|
||||||
|
notification is suppressed; a failed report does not stop later reports.
|
||||||
|
|
||||||
`GenerateReport` requires a non-nil normalized bundle and then performs this
|
Batch notification is skipped when disabled or when any report failed.
|
||||||
ordered work:
|
Successful notification uses the completed managed report paths only. Batch
|
||||||
|
items retain preparation, execution, and optional debug paths when reached.
|
||||||
|
|
||||||
1. Select a state store, determine artifact destinations, and locate a prior
|
## Inspection And Boundaries
|
||||||
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
|
Inspection loads persisted state only. It does not collect weather, invoke
|
||||||
run boundary. Generated-text-template reports look up their catalog definition,
|
Promptkit, or upload reports. The app coordinates project-owned contracts but
|
||||||
run structured Scriptorium output to the raw artifact, preserve any structured
|
does not parse flags, load YAML, implement transport, construct provider SDKs,
|
||||||
run result, validate and save generated text, build and save a render context,
|
or define report-period policy.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -25,19 +25,14 @@ requires a change between its low, possible, likely, and high categories.
|
|||||||
| Comparator | Required snapshot data | Compared values |
|
| 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 |
|
| `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
|
For daily comparison, `alert_digest` and `precip_timing` are optional: alerts
|
||||||
are compared when present, and timing is compared only when both snapshots
|
are compared when present, and timing is compared only when both snapshots
|
||||||
contain it. The multi-day comparators build their day map from daypart
|
contain it.
|
||||||
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
|
The application selects a comparator only after state lookup establishes a
|
||||||
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator;
|
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator.
|
||||||
Three-day and Weekend use their named comparators. Other report types, such as
|
Hourly reports do not produce a Recent Changes list.
|
||||||
Storm, produce no Recent Changes list.
|
|
||||||
|
|
||||||
## Missing data and failures
|
## Missing data and failures
|
||||||
|
|
||||||
@@ -51,8 +46,8 @@ behavior. It does not decide report compatibility or retain snapshots.
|
|||||||
|
|
||||||
## Verification and invariants
|
## Verification and invariants
|
||||||
|
|
||||||
Focused tests cover the daily, three-day, and weekend strategies, threshold
|
Focused tests cover the daily strategy, threshold boundaries, indicator and
|
||||||
boundaries, indicator and alert changes, and missing required stanzas:
|
alert changes, and missing required stanzas:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/changes
|
go test ./internal/changes
|
||||||
|
|||||||
@@ -1,65 +1,27 @@
|
|||||||
# CLI Internals
|
# CLI Internals
|
||||||
|
|
||||||
`internal/cli` turns process arguments into application requests and translates
|
`internal/cli` parses terminal arguments, loads configuration, constructs app
|
||||||
application results into terminal output. The user-facing command, flag, and
|
requests, and translates app results to bounded JSON summaries. The user
|
||||||
output contract belongs in the [CLI reference](../cli.md).
|
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
|
||||||
the package parses command-specific and common flags, loads configuration with
|
Promptkit executor after configuration loads. It passes the executor and any
|
||||||
CLI overrides, obtains the current time, and constructs either an
|
`--llm-debug-dir` request into the app. `run` accepts the debug flag as well
|
||||||
`app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and
|
as `generate`; the app, not the CLI, secures and initializes the debug root.
|
||||||
batch execution to `internal/app`.
|
|
||||||
|
|
||||||
For inspection, it loads configuration, builds the appropriate app inspection
|
Summaries include identity, status, safe artifact paths, and notification
|
||||||
request, and writes the returned value. Inspection is read-only; the inspected
|
provenance. They intentionally exclude module values, YAML package bodies, raw
|
||||||
artifact types and user invocation remain owned by the [CLI reference](../cli.md)
|
generated text, rendered prompts, schemas, endpoints, credentials, and full
|
||||||
and [operations guide](../operations.md).
|
Distributor payloads. A failed action with a partial result still emits its
|
||||||
|
safe summary before its error is returned.
|
||||||
|
|
||||||
## Result Translation
|
CLI code owns no report policy, weather collection, persistence, 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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +53,7 @@ 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
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ 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
|
||||||
@@ -33,7 +33,7 @@ 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, state persistence, and template asset lookup
|
||||||
remain outside this package.
|
remain outside this package.
|
||||||
|
|
||||||
## Verification and invariants
|
## Verification and invariants
|
||||||
@@ -47,5 +47,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||||
Recent Changes, and source warnings into the YAML `data_package` consumed by
|
Recent Changes, and source warnings into the YAML `data_package` consumed by
|
||||||
Scriptorium. It owns this package's schema, grouping, serialization, loading,
|
Promptkit. It owns this package's schema, grouping, serialization, loading,
|
||||||
and validation—not weather collection, module construction, path choice, or
|
and validation—not weather collection, module construction, path choice, or
|
||||||
subprocess execution.
|
provider execution.
|
||||||
|
|
||||||
## Package construction
|
## Package construction
|
||||||
|
|
||||||
|
|||||||
24
docs/internal/promptkit-adapter.md
Normal file
24
docs/internal/promptkit-adapter.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Promptkit Adapter Internals
|
||||||
|
|
||||||
|
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit.
|
||||||
|
The CLI maps `promptkit` configuration to a `PromptExecutorConfig` and constructs one executor
|
||||||
|
per action. Promptkit dependency types do not escape the adapter.
|
||||||
|
|
||||||
|
The adapter exposes exact prompt and profile inspection plus prepared execution. It maps Promptkit
|
||||||
|
inspection values to project-owned prompt input, output-contract, profile, preparation, execution,
|
||||||
|
validation, and optional debug values. It classifies adapter failures without copying provider secrets
|
||||||
|
or unbounded response bodies into application errors or normal state.
|
||||||
|
|
||||||
|
The app calls the executor's preparation callback before provider execution to persist safe preparation
|
||||||
|
provenance. Completed executions are then persisted as safe execution provenance and raw generated text
|
||||||
|
is validated by `internal/generatedtext`. The adapter does not write workspace state, render Markdown,
|
||||||
|
choose report definitions, or send Distributor notifications.
|
||||||
|
|
||||||
|
Focused tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
The public logical prompt/profile/schema contract is owned by the
|
||||||
|
[Promptkit integration guide](../integrations/promptkit.md).
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
`internal/report` owns the registry of report identities and the data declared
|
`internal/report` owns the registry of report identities and the data declared
|
||||||
for each one: resolution, generation mode, prompt identity, comparison policy,
|
for each one: resolution, prompt identity and version, comparison policy,
|
||||||
artifact group, output-copy name, default module composition, and Distributor
|
artifact group, output-copy name, default module composition, and Distributor
|
||||||
path declarations. The public command syntax is owned by the
|
path declarations. The public command syntax is owned by the
|
||||||
[CLI reference](../cli.md); configuration aliases and overrides are owned by
|
[CLI reference](../cli.md); configuration aliases and overrides are owned by
|
||||||
@@ -10,34 +10,28 @@ 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, generation
|
||||||
mode, optional template and generated-text schema IDs, valid-period resolver,
|
version, template and generated-text schema IDs, valid-period resolver,
|
||||||
comparison strategy, artifact group, batch-copy filename, Distributor path
|
comparison strategy, artifact group, batch-copy filename, Distributor path
|
||||||
templates, generation eligibility, compatible prior IDs, default modules, and
|
templates, generation eligibility, compatible prior IDs, default modules, and
|
||||||
batch eligibility flags. `Resolved` combines that definition with the valid
|
batch eligibility flags. `Resolved` combines that definition with the valid
|
||||||
period and run metadata for one invocation.
|
period and run metadata for one invocation.
|
||||||
|
|
||||||
| Report ID | Mode | Period policy | Comparison | Registry batch flag | Output copy |
|
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `daily` | Generated text + template | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
|
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
|
||||||
| `today` | Generated text + template | Selected or current local civil day | Same valid date | Morning | `today.md` |
|
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` |
|
||||||
| `tomorrow` | Generated text + template | Next local civil day | Same valid date | Evening | `tomorrow.md` |
|
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
|
||||||
| `hourly` | Generated text + template | Rolling six-hour interval | Rolling window | — | `hourly.md` |
|
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `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
|
Each report pairs its ID and prompt version with matching template and schema
|
||||||
schema IDs. The three direct-Markdown reports leave both IDs empty. Exact
|
IDs. Exact template fields and schema assets belong to [report templates](../templates.md)
|
||||||
template fields and schema assets belong to [report templates](../templates.md)
|
|
||||||
and [generated-text internals](generatedtext.md).
|
and [generated-text internals](generatedtext.md).
|
||||||
|
|
||||||
All valid periods are half-open. Storm accepts local `YYYY-MM-DDTHH:MM` values
|
All valid periods are half-open.
|
||||||
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
|
## Registry collaborators
|
||||||
|
|
||||||
`DefaultRegistry` is the only source of the seven report definitions.
|
`DefaultRegistry` is the only source of the four report definitions.
|
||||||
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
|
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
|
||||||
report identity rules. Registry overrides clone a definition and replace its
|
report identity rules. Registry overrides clone a definition and replace its
|
||||||
module list only after the report ID is recognized.
|
module list only after the report ID is recognized.
|
||||||
@@ -54,13 +48,12 @@ membership and produces the actual batch plan.
|
|||||||
|
|
||||||
Each definition supplies an ordered `[]module.ConfigItem`; the complete
|
Each definition supplies an ordered `[]module.ConfigItem`; the complete
|
||||||
report-to-module mapping is maintained in [module internals](module.md).
|
report-to-module mapping is maintained in [module internals](module.md).
|
||||||
`ArtifactGroup`, `BatchOutputName`, `Generated`, and comparison compatibility
|
`ArtifactGroup`, `BatchOutputName`, and comparison compatibility
|
||||||
are likewise consumed by state and orchestration rather than recomputed there.
|
are likewise consumed by state and orchestration rather than recomputed there.
|
||||||
|
|
||||||
Unknown report IDs or batch names, an invalid weekend resolution, and invalid
|
Unknown report IDs or batch names return errors. The registry never collects
|
||||||
storm windows return errors. The registry never collects weather data, builds
|
weather data, builds modules, parses CLI flags, writes state, executes
|
||||||
modules, parses CLI flags, writes state, executes Scriptorium, or delivers a
|
Promptkit, or delivers a report.
|
||||||
report.
|
|
||||||
|
|
||||||
## Verification and invariants
|
## Verification and invariants
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,91 +1,56 @@
|
|||||||
# State Internals
|
# State Internals
|
||||||
|
|
||||||
The `internal/state` package owns filesystem-backed run state: safe path
|
`internal/state` owns safe workspace paths, atomic artifact writes, metadata,
|
||||||
derivation, metadata persistence, prior-report lookup, and read-only report
|
prior-snapshot lookup, and read-only inspection. Operators should use the
|
||||||
inspection. It does not decide which reports to generate or deliver. For the
|
[operations guide](../operations.md) for lifecycle and retention.
|
||||||
operator-facing layout and retention procedures, see the
|
|
||||||
[operations guide](../operations.md).
|
|
||||||
|
|
||||||
## Store construction and artifact paths
|
## Artifact Paths
|
||||||
|
|
||||||
`NewFilesystemStore` requires a workspace root and rejects absolute or
|
For each run, paths are grouped by artifact group and valid start date:
|
||||||
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 |
|
| Artifact | Location |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
|
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
|
||||||
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
|
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
|
||||||
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
|
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
|
||||||
| Render preflight | `preflight/<group>/<date>/render.<run-id>.json` |
|
| Prompt preparation | `preflight/<group>/<date>/prompt_preparation.<run-id>.json` |
|
||||||
| Notification record | `notifications/<group>/<date>/distributor.<run-id>.json` |
|
| Prompt execution | `snapshots/<group>/<date>/prompt_execution.<run-id>.json` |
|
||||||
|
| Raw generated text | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` |
|
||||||
|
| Validated generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
|
||||||
|
| Render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
|
||||||
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
|
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
|
||||||
| Generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
|
| Notification | `notifications/<group>/<date>/distributor.<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
|
Batch notification records are `notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
|
||||||
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
|
## Metadata And Debug Storage
|
||||||
`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
|
New metadata is `weatherreporter.metadata.v2` and gains preparation and
|
||||||
|
execution paths only after those artifacts are saved. Legacy V1 records remain
|
||||||
|
readable; their historic preflight and generated-text-result fields are mapped
|
||||||
|
to the corresponding preparation and execution views during inspection. New
|
||||||
|
runs never write V1 records.
|
||||||
|
|
||||||
`Metadata` is the durable inventory for a run. It records its schema version,
|
Prompt preparation and execution records are validated on both save and load.
|
||||||
run identity, generated and valid timestamps, artifact group and mode, source
|
They require exact report/prompt identity, complete timing, internally
|
||||||
content and provenance, and the module snapshot, data-package, preflight,
|
consistent provenance, and status-appropriate validation or bounded classified
|
||||||
report, generated-artifact, and notification locations when present.
|
errors. Completed execution provenance keeps Promptkit's run identity distinct
|
||||||
|
from the Weatherreporter run identity.
|
||||||
|
|
||||||
`BuildMetadataFromBriefingMetadata` establishes the common fields; the
|
For a completed prompt run, the execution record is atomically replaced after
|
||||||
application adds locations as artifacts are produced. `SaveMetadata` requires
|
each downstream artifact is saved. Its path set therefore records the raw and
|
||||||
the run ID and the module snapshot, data-package, preflight, and metadata
|
normalized generated text, render context, managed report, requested output
|
||||||
paths. The package also saves module snapshots, data packages, preflight
|
copy, and notification artifact actually reached without changing the original
|
||||||
records, generated-text artifacts, render contexts, and notifications. JSON
|
Promptkit outcome.
|
||||||
writes use `fileutil.WriteJSONAtomic`, so readers do not observe a partially
|
|
||||||
written state file.
|
|
||||||
|
|
||||||
The data package itself follows the shared
|
`PromptDebugWriter` is separate from workspace state. An empty root disables
|
||||||
[prompt-input contract](prompt-input.md). Report text, templates, and external
|
it. An enabled absolute root is checked for safe directories and symlinks, then
|
||||||
delivery payloads remain owned by their respective packages and integration
|
stores `preparation.json` and `execution.json` beneath
|
||||||
references.
|
`<root>/<report-id>/<valid-date>/<run-id>/`. Directories are `0700`; files are
|
||||||
|
atomic `0600`. Normal state discovery does not read this root.
|
||||||
|
|
||||||
## Prior reports and inspection
|
Focused checks:
|
||||||
|
|
||||||
`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
|
```sh
|
||||||
go test ./internal/state
|
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.
|
|
||||||
|
|||||||
@@ -15,24 +15,39 @@ weatherreporter generate today --out ./today.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
A generation collects weather data, resolves the report period, builds and
|
A generation collects weather data, resolves the report period, builds and
|
||||||
persists the module snapshot and prompt data package, runs Scriptorium
|
persists the module snapshot and prompt data package, records Promptkit
|
||||||
preflight, then produces the managed Markdown report. Daily, Today, Tomorrow,
|
preparation provenance before provider execution, then persists raw output and
|
||||||
and Hourly reports additionally persist generated-text artifacts, validate the
|
execution provenance, validates the structured generated text, and renders the
|
||||||
structured generated text, and render Markdown from the validated text and
|
managed Markdown report from the validated text and deterministic values.
|
||||||
deterministic values.
|
|
||||||
|
|
||||||
The managed report and its final metadata are saved before single-report
|
The managed report and its final metadata are saved before single-report
|
||||||
Distributor notification is attempted. `--out` writes an extra operator copy;
|
Distributor notification is attempted. `--out` writes an extra operator copy;
|
||||||
it never changes the managed report or upload source. A successful generate
|
it never changes the managed report or upload source. A successful generate
|
||||||
command prints its summary to stdout unless `--quiet` is used.
|
command prints its summary to stdout unless `--quiet` is used.
|
||||||
|
|
||||||
|
## Optional Prompt Debug Capture
|
||||||
|
|
||||||
|
Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
The directory must be absolute and is initialized before prompt inspection or
|
||||||
|
weather collection. Capture files are stored outside the managed workspace,
|
||||||
|
with restrictive permissions, under the report ID, valid date, and RunID.
|
||||||
|
They can contain rendered prompts and generated output, so the normal metadata,
|
||||||
|
CLI summary, and routine logs contain only the optional directory path—not
|
||||||
|
their content. A capture-write failure stops that run before later work can
|
||||||
|
continue.
|
||||||
|
|
||||||
Run a scheduled batch with the same configured collection:
|
Run a scheduled batch with the same configured collection:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter run morning --out-dir ./reports
|
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Each batch collects once before it plans reports. Morning runs Today, Tomorrow,
|
Each batch validates its configured prompt/profile candidates, then collects once before it plans reports. Morning runs Today, Tomorrow,
|
||||||
and every eligible dated Daily Report; evening runs Tomorrow and the same
|
and every eligible dated Daily Report; evening runs Tomorrow and the same
|
||||||
eligible Daily Reports. Eligible Daily dates begin after tomorrow and require
|
eligible Daily Reports. Eligible Daily dates begin after tomorrow and require
|
||||||
complete hourly coverage for their entire local civil day. A batch continues
|
complete hourly coverage for their entire local civil day. A batch continues
|
||||||
@@ -57,22 +72,23 @@ workspace/
|
|||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/prompt_execution.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
|
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/prompt_preparation.<run_id>.json
|
||||||
|
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated-text and render-context artifacts are written only by Daily,
|
The generated-text and render-context artifacts are written for every completed
|
||||||
Today, Tomorrow, and Hourly reports. A report's metadata links the module
|
single-report generation.
|
||||||
snapshot, data package, preflight artifact, managed report, and any available
|
A report's metadata links the module snapshot, data package, preparation and
|
||||||
generated-text or single-report notification artifact. Batch notification
|
execution receipts, managed report, generated-text artifacts, and any available single-report
|
||||||
artifacts are separate batch-level records under `notifications/batches`.
|
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
|
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
|
also contains its local valid date so multiple Daily reports in one batch have
|
||||||
@@ -105,7 +121,7 @@ redacted errors; they do not contain tokens.
|
|||||||
## Inspecting Stored Runs
|
## Inspecting Stored Runs
|
||||||
|
|
||||||
Inspection is read-only: it neither collects weather data nor invokes
|
Inspection is read-only: it neither collects weather data nor invokes
|
||||||
Scriptorium or Distributor. Start by finding a RunID:
|
Promptkit or Distributor. Start by finding a RunID:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
@@ -124,17 +140,23 @@ weatherreporter inspect metadata RUN_ID
|
|||||||
A missing snapshots directory produces no listed reports. An unknown or empty
|
A missing snapshots directory produces no listed reports. An unknown or empty
|
||||||
RunID is an error; use `inspect reports` to obtain a valid value.
|
RunID is an error; use `inspect reports` to obtain a valid value.
|
||||||
|
|
||||||
|
New runs write `weatherreporter.metadata.v2`, including preparation and
|
||||||
|
execution references once those receipts exist. `inspect metadata` also reads
|
||||||
|
historic V1 records; their legacy preflight and generated-text-result fields
|
||||||
|
remain visible for compatibility, but Weatherreporter does not write them for
|
||||||
|
new runs.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
Keep the workspace when a run fails: artifacts reached before the failure
|
Keep the workspace when a run fails: artifacts reached before the failure
|
||||||
remain available where they can be safely persisted.
|
remain available where they can be safely persisted.
|
||||||
|
|
||||||
- A preflight failure can leave the preflight artifact and metadata.
|
- A preparation failure can leave its classified receipt and metadata.
|
||||||
- A report-generation failure can leave the managed report, module snapshot,
|
- A report-generation failure can leave the managed report, module snapshot,
|
||||||
data package, and metadata.
|
data package, and metadata.
|
||||||
- A generated-text failure can leave raw text, the structured run result, or a
|
- A completed prompt validation rejection leaves raw text, an execution receipt,
|
||||||
validated generated-text and render-context artifact, depending on where it
|
and metadata. Later generated-text failures can also leave validated text and
|
||||||
stopped.
|
a render-context artifact, depending on where they stopped.
|
||||||
- A single-report notification failure preserves the report and final metadata,
|
- A single-report notification failure preserves the report and final metadata,
|
||||||
including its notification artifact when it was written.
|
including its notification artifact when it was written.
|
||||||
- A batch notification failure preserves each report's artifacts and adds the
|
- A batch notification failure preserves each report's artifacts and adds the
|
||||||
@@ -147,7 +169,7 @@ first response; retain it until the failure is understood.
|
|||||||
|
|
||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
- Workspace files, generated reports, and Scriptorium stderr can contain
|
- Workspace files and generated reports can contain
|
||||||
sensitive operational context. Set appropriate filesystem permissions and do
|
sensitive operational context. Set appropriate filesystem permissions and do
|
||||||
not publish them unintentionally.
|
not publish them unintentionally.
|
||||||
- Weatherreporter uses one configured Weather API endpoint and local workspace
|
- Weatherreporter uses one configured Weather API endpoint and local workspace
|
||||||
|
|||||||
@@ -2,217 +2,72 @@
|
|||||||
|
|
||||||
## 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,
|
compares prior snapshots, executes exact-version Promptkit prompts, validates
|
||||||
and invokes Scriptorium either to produce managed Markdown directly or to
|
structured generated prose, and renders repository-owned Markdown. Completed
|
||||||
produce bounded generated-text prose for repository-owned templates. It
|
managed Markdown may be uploaded through Distributor.
|
||||||
persists inspectable artifacts and can upload completed reports through
|
|
||||||
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 executes planned 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 workflow order, partial results, and notification
|
||||||
5. structured prior-snapshot comparison;
|
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.
|
||||||
|
- `internal/state` owns workspace paths, V2 metadata, atomic persistence, and
|
||||||
|
read-only inspection.
|
||||||
|
|
||||||
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 inspects the exact prompt version and output contract before
|
||||||
|
collection. The selected profile is configured explicitly or declared by the
|
||||||
|
prompt; unsupported direct-key profiles and missing reported credentials fail
|
||||||
|
before collection.
|
||||||
|
- Prepared execution persists safe preparation provenance before provider work.
|
||||||
|
Completed execution persists safe execution provenance; raw output is
|
||||||
|
validated before template rendering.
|
||||||
|
- Generated text fills defined prose slots only. Deterministic facts remain
|
||||||
|
authoritative and repository-owned templates produce all managed Markdown.
|
||||||
|
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
||||||
|
credentials never enter normal metadata, summaries, logs, or workspace
|
||||||
|
artifacts. They are written only to an explicit secure debug root when
|
||||||
|
requested.
|
||||||
|
|
||||||
The binary entry point should do no business work beyond constructing and
|
## State, 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,
|
- Managed writes are atomic where practical and stay beneath the configured
|
||||||
artifact layout, Recent Changes comparison, external transport, or subprocess
|
workspace root. Reached artifacts remain inspectable after later failures.
|
||||||
construction.
|
- New records use `weatherreporter.metadata.v2`; V1 records remain readable for
|
||||||
|
inspection compatibility.
|
||||||
### Configuration
|
- Distributor uploads use only the managed Markdown report, never output copies
|
||||||
|
or workspace scans. Notification follows report and final metadata success.
|
||||||
Configuration loading, built-in defaults, overrides, secret loading, and
|
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
||||||
validation belong to `internal/config`. Operational values shared across
|
rather than live provider calls. See the [testing policy](testing.md).
|
||||||
packages must be explicit configuration or constants owned by the responsible
|
|
||||||
package, not hidden in CLI or adapter code.
|
|
||||||
|
|
||||||
The exact configuration contract belongs in the
|
|
||||||
[configuration reference](../config.md). Other architecture documents should
|
|
||||||
state ownership and safety rules rather than repeat fields, defaults, or
|
|
||||||
precedence.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -82,12 +82,14 @@ 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, runtime state lifecycle, and loading implementation. |
|
||||||
| Operations | `docs/operations.md` | Normal workflows, physical workspace layout, artifacts and metadata, inspection, notification behavior, recovery, cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
| Operations | `docs/operations.md` | Normal workflows, physical workspace layout, artifacts and metadata, inspection, notification behavior, recovery, cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
||||||
| Troubleshooting | `docs/troubleshooting.md` | Recurring symptoms, likely causes, diagnostic steps, safe fixes, and links to normal-operation references. | Complete command and configuration references, routine operating procedures, and implementation detail. |
|
| 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. |
|
||||||
@@ -131,6 +133,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 +216,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;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -198,7 +198,7 @@ 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, persistence, partial success, and
|
||||||
failure propagation.
|
failure propagation.
|
||||||
- State tests own path derivation, atomic artifacts, lookup, and round trips.
|
- State tests own path derivation, atomic artifacts, lookup, and round trips.
|
||||||
@@ -220,7 +220,7 @@ observation:
|
|||||||
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, saving
|
||||||
metadata before notification, propagating cancellation to Scriptorium, or
|
metadata before notification, 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
269
docs/release.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Release Procedure
|
||||||
|
|
||||||
|
## Release Model
|
||||||
|
|
||||||
|
Weatherreporter publishes executable binaries through tagged commits on
|
||||||
|
`main`. Releases use stable semantic-version tags in the form
|
||||||
|
`vMAJOR.MINOR.PATCH`. The current pipeline does not publish prereleases.
|
||||||
|
|
||||||
|
Every release has one nonempty, version-matched note at
|
||||||
|
`docs/releases/<tag>.md`. After the tag is pushed, the Woodpecker release
|
||||||
|
pipeline validates the tagged source, builds six binaries, creates SHA-256
|
||||||
|
checksums, and creates the corresponding Gitea release. The pipeline uses the
|
||||||
|
checked-in release note as the Gitea release body and does not overwrite an
|
||||||
|
existing release.
|
||||||
|
|
||||||
|
Before `v1.0.0`, a minor release may deliberately change user-facing
|
||||||
|
interfaces when its release note explains the compatibility impact and
|
||||||
|
required operator action. Patch releases must not intentionally break the
|
||||||
|
documented CLI, configuration, durable artifact, or integration contracts in
|
||||||
|
their minor line.
|
||||||
|
|
||||||
|
Published tags and their generated releases are immutable. Never move, reuse,
|
||||||
|
or delete a published tag, and never manually overwrite the release produced
|
||||||
|
from it.
|
||||||
|
|
||||||
|
## Select The Version And Write The Release Note
|
||||||
|
|
||||||
|
Choose an unpublished version and export it as `RELEASE_VERSION`. Run the
|
||||||
|
commands in this procedure from the Weatherreporter repository root in one
|
||||||
|
POSIX shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export RELEASE_VERSION=vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `docs/releases/$RELEASE_VERSION.md` with this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Weatherreporter vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
This release ...
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Summarize the release's purpose and most important outcomes.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
State compatibility with the preceding release and identify any changed CLI,
|
||||||
|
configuration, durable artifact, integration, or operating contract.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
State the operator actions required to upgrade, or state that no special
|
||||||
|
action is required.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
Describe the material user-visible, operational, and maintainer-visible
|
||||||
|
changes. Link to canonical documentation for exact current contracts.
|
||||||
|
```
|
||||||
|
|
||||||
|
The note is a concise changelog and adoption aid, not a replacement for current
|
||||||
|
documentation. Update every affected canonical document in the same candidate
|
||||||
|
commit. Do not include credentials, private infrastructure details, or claims
|
||||||
|
that are not true of the candidate.
|
||||||
|
|
||||||
|
Require the version, path, heading, and minimum sections before continuing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
|
||||||
|
if ! printf '%s\n' "$RELEASE_VERSION" |
|
||||||
|
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
|
then
|
||||||
|
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_NOTE="docs/releases/$RELEASE_VERSION.md"
|
||||||
|
export RELEASE_NOTE
|
||||||
|
|
||||||
|
test -s "$RELEASE_NOTE"
|
||||||
|
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
|
||||||
|
grep -Fx '## Summary' "$RELEASE_NOTE"
|
||||||
|
grep -Fx '## Compatibility' "$RELEASE_NOTE"
|
||||||
|
grep -Fx '## Upgrade' "$RELEASE_NOTE"
|
||||||
|
grep -Fx '## Changes' "$RELEASE_NOTE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate The Candidate
|
||||||
|
|
||||||
|
Run the same substantive checks enforced by the tag pipeline before committing
|
||||||
|
the release note:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test -z "$(git ls-files go.work go.work.sum)"
|
||||||
|
test ! -e vendor
|
||||||
|
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||||
|
then
|
||||||
|
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GOWORK=off go test -count=1 ./...
|
||||||
|
GOWORK=off go test -race -count=1 ./...
|
||||||
|
GOWORK=off go vet ./...
|
||||||
|
GOWORK=off go build ./...
|
||||||
|
GOWORK=off go mod tidy -diff
|
||||||
|
|
||||||
|
unformatted=$(
|
||||||
|
git ls-files '*.go' |
|
||||||
|
while IFS= read -r go_file
|
||||||
|
do
|
||||||
|
gofmt -l "$go_file"
|
||||||
|
done
|
||||||
|
)
|
||||||
|
test -z "$unformatted"
|
||||||
|
git diff --check
|
||||||
|
git diff --cached --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow every added or changed Markdown link and confirm that its local target
|
||||||
|
exists. Review the candidate for generated binaries, test output, credentials,
|
||||||
|
temporary files, workspace files, replacements, vendored dependencies, and
|
||||||
|
other files that do not belong in source control.
|
||||||
|
|
||||||
|
## Publish The Candidate Commit
|
||||||
|
|
||||||
|
Commit the release note and any final current-state documentation updates, then
|
||||||
|
push `main` through the ordinary repository workflow:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add "$RELEASE_NOTE"
|
||||||
|
git commit -m "Document Weatherreporter $RELEASE_VERSION"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not tag an uncommitted or unpushed candidate. Record and export the exact
|
||||||
|
candidate commit after the push:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
|
||||||
|
export RELEASE_COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guard And Tag The Candidate
|
||||||
|
|
||||||
|
Run this guard immediately before creating the tag. It requires a clean
|
||||||
|
checkout on synchronized `main`, valid module hygiene, the version-matched
|
||||||
|
release note, and an unpublished local and remote tag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
check_release_candidate() {
|
||||||
|
test "$(git branch --show-current)" = main
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
|
||||||
|
gowork_value=$(go env GOWORK)
|
||||||
|
case "$gowork_value" in
|
||||||
|
''|off) ;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "active Go workspace: $gowork_value" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
test -z "$(git ls-files go.work go.work.sum)"
|
||||||
|
test ! -e vendor
|
||||||
|
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||||
|
then
|
||||||
|
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
test -s "$RELEASE_NOTE"
|
||||||
|
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
|
||||||
|
|
||||||
|
git fetch origin main --tags
|
||||||
|
test "$RELEASE_COMMIT" = \
|
||||||
|
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
|
||||||
|
then
|
||||||
|
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if test -n "$(
|
||||||
|
git ls-remote --tags origin \
|
||||||
|
"refs/tags/$RELEASE_VERSION" \
|
||||||
|
"refs/tags/$RELEASE_VERSION^{}"
|
||||||
|
)"
|
||||||
|
then
|
||||||
|
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_release_candidate
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a lightweight tag, matching Weatherreporter's existing release tags,
|
||||||
|
and bind it explicitly to the guarded commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag "$RELEASE_VERSION" "$RELEASE_COMMIT"
|
||||||
|
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = commit
|
||||||
|
test "$(git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}")" = \
|
||||||
|
"$RELEASE_COMMIT"
|
||||||
|
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
If inspection finds an error, delete the unpublished local tag, correct the
|
||||||
|
candidate, and repeat the procedure. Once the tag is pushed, it is immutable.
|
||||||
|
|
||||||
|
## Publish And Verify The Release
|
||||||
|
|
||||||
|
Push only the selected tag ref. Do not use `git push --tags`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin \
|
||||||
|
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag event starts the release pipeline. Its validation step rejects a
|
||||||
|
non-stable semantic tag, a missing release note, module or repository hygiene
|
||||||
|
violations, and any failing test, race test, vet, build, module-tidiness,
|
||||||
|
formatting, or whitespace check. Its build step also verifies that the host
|
||||||
|
binary reports `weatherreporter $RELEASE_VERSION`.
|
||||||
|
|
||||||
|
Wait for the pipeline to succeed, then confirm that the Gitea release:
|
||||||
|
|
||||||
|
- targets `RELEASE_COMMIT` through `RELEASE_VERSION`;
|
||||||
|
- is titled `Weatherreporter $RELEASE_VERSION`;
|
||||||
|
- uses `RELEASE_NOTE` from the tagged commit as its body;
|
||||||
|
- contains `SHA256SUMS`; and
|
||||||
|
- contains Linux, macOS, and Windows binaries for both `amd64` and `arm64`,
|
||||||
|
named `weatherreporter-$RELEASE_VERSION-<os>-<arch>` with `.exe` on Windows.
|
||||||
|
|
||||||
|
Compare the remote tag with the guarded commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
remote_commit=$(
|
||||||
|
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
|
||||||
|
awk 'NR == 1 { print $1 }'
|
||||||
|
)
|
||||||
|
test "$remote_commit" = "$RELEASE_COMMIT"
|
||||||
|
```
|
||||||
|
|
||||||
|
Download `SHA256SUMS` and every release binary into a new temporary directory,
|
||||||
|
run `sha256sum --check SHA256SUMS`, and execute the binary for the maintainer's
|
||||||
|
host platform with `--version`. It must print exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
weatherreporter vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
## Failed Publication And Corrections
|
||||||
|
|
||||||
|
If the tag pipeline fails after publication, preserve the tag and diagnose the
|
||||||
|
failure from the pipeline logs. Fix the cause on `main`, select a new patch
|
||||||
|
version, prepare a new release note, and repeat the complete procedure. Do not
|
||||||
|
move or recreate the failed published tag.
|
||||||
|
|
||||||
|
Do not manually edit an automatically generated Gitea release or republish its
|
||||||
|
assets. A wording-only correction may be committed to the historical document
|
||||||
|
on `main`, with an explicit correction note, but it does not alter the file at
|
||||||
|
the tag or the generated release. Publish a new patch release when the error is
|
||||||
|
material to installation, compatibility, security, or operation.
|
||||||
134
docs/releases/v0.9.0.md
Normal file
134
docs/releases/v0.9.0.md
Normal 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.
|
||||||
@@ -7,8 +7,7 @@ status; current behavior is documented outside `docs/roadmap/`.
|
|||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ Possible direction:
|
|||||||
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 Scriptorium or another narrow evaluator adapter.
|
||||||
3. Persist storm lifecycle state.
|
3. Persist storm lifecycle state.
|
||||||
4. Generate or update Storm Reports only when a meaningful event is present.
|
4. Generate or update 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 +28,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
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ Possible future modules:
|
|||||||
Changes
|
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
|
||||||
|
|||||||
555
docs/roadmap/implementation.md
Normal file
555
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
# Promptkit Migration Implementation Plan
|
||||||
|
|
||||||
|
Status: Completed; Stages 1–19 passed their exit gates.
|
||||||
|
|
||||||
|
## Purpose And Authority
|
||||||
|
|
||||||
|
This document records the completed implementation of the
|
||||||
|
[Promptkit migration roadmap](promptkit.md) and its post-implementation audit
|
||||||
|
remediation. The feature roadmap records scope, user intent, policy choices,
|
||||||
|
and the implemented end state. This plan records implementation sequence,
|
||||||
|
tests, and completion gates.
|
||||||
|
|
||||||
|
Stages 12–19 were completed in order. They fixed additional defects exposed by
|
||||||
|
their required tests only when those defects were within the same stated
|
||||||
|
contract; they did not add new product behavior or reinterpret roadmap
|
||||||
|
decisions.
|
||||||
|
|
||||||
|
This plan follows the repository's
|
||||||
|
[architecture](../policy/architecture.md),
|
||||||
|
[documentation](../policy/documentation.md), and
|
||||||
|
[testing](../policy/testing.md) policies.
|
||||||
|
|
||||||
|
## Continuing Invariants
|
||||||
|
|
||||||
|
- Keep `gitea.maximumdirect.net/eric/promptkit` pinned at exactly `v0.4.0`.
|
||||||
|
- Keep Promptkit types inside `internal/adapters/promptkit`, its tests, and the
|
||||||
|
external prompt-asset contract test.
|
||||||
|
- Preserve one Promptkit engine per `generate` or `run` invocation and one
|
||||||
|
shared engine for every sequential report in a batch.
|
||||||
|
- Preserve exact prompt version `1.0.0`, the exact persisted YAML data-package
|
||||||
|
bytes, prepared execution, and preparation persistence before provider work.
|
||||||
|
- Do not add retries, repair attempts, concurrent batch generation, direct
|
||||||
|
Markdown generation, arbitrary backend registration, or live-provider
|
||||||
|
tests.
|
||||||
|
- Keep ordinary artifacts, errors, logs, and summaries free of credentials,
|
||||||
|
rendered messages, schemas, input bodies, generated bodies, endpoints, and
|
||||||
|
full effective parameter maps.
|
||||||
|
- Keep sensitive debug artifacts opt-in, outside normal state, owner-only,
|
||||||
|
atomic, and free of credentials.
|
||||||
|
- Treat an artifact path as reached only after the corresponding write or copy
|
||||||
|
succeeds. Never persist or summarize a merely derivable future path.
|
||||||
|
- Preserve every safe reached path in partial app and CLI results even when a
|
||||||
|
later persistence, validation, rendering, copy, or notification step fails.
|
||||||
|
- Keep v1 metadata read compatibility and write only v2 metadata for new runs.
|
||||||
|
- Keep the default test suite deterministic, offline, and credential-free.
|
||||||
|
- Run `git diff --check` before completing every stage. Run the full repository
|
||||||
|
gate in Stage 19.
|
||||||
|
|
||||||
|
## Completed Migration Summary
|
||||||
|
|
||||||
|
Stages 1–11 are implemented and committed. They remain summarized here to
|
||||||
|
preserve the history and dependencies of the follow-up work.
|
||||||
|
|
||||||
|
| Stage | Completed outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | Removed the unfinished three-day, weekend, and storm product surfaces and retained Daily, Today, Tomorrow, and Hourly with exact prompt version `1.0.0`. |
|
||||||
|
| 2 | Promoted the four operational prompts and canonical schemas into the embedded `internal/promptassets` source used by Promptkit and generated-text validation. |
|
||||||
|
| 3 | Added the project-owned `internal/promptexec` inspection, preparation, execution, validation, debug, and error contract. |
|
||||||
|
| 4 | Added the Promptkit v0.4.0 adapter with prepared execution, explicit value mapping, safe error classification, and offline model-client tests. |
|
||||||
|
| 5 | Added Promptkit-era preparation and execution artifacts, metadata v2, new paths, and v1 decoding support. |
|
||||||
|
| 6 | Added explicitly rooted, permission-restricted, atomic LLM debug persistence. |
|
||||||
|
| 7 | Added Promptkit configuration, executor composition, and pre-collection prompt/profile/credential inspection. |
|
||||||
|
| 8 | Cut single-report generation over to prepared Promptkit execution and v2 persistence. |
|
||||||
|
| 9 | Added `--llm-debug-dir` and Promptkit-era single-report summary fields. |
|
||||||
|
| 10 | Cut morning and evening batches over to one shared Promptkit executor and removed Scriptorium code, configuration, and dependency metadata. |
|
||||||
|
| 11 | Updated canonical Promptkit documentation, removed the temporary Scriptorium corpus, and ran the available repository checks. |
|
||||||
|
|
||||||
|
The post-implementation audit confirmed the principal dependency and package
|
||||||
|
boundaries, but found incorrect reached-path bookkeeping, incomplete execution
|
||||||
|
artifact updates, insufficient artifact validation, extensive loss of
|
||||||
|
behavioral tests during the final cutover, and roadmap lifecycle text that was
|
||||||
|
not finalized. The completed remediation addressed those findings without
|
||||||
|
changing the intended feature scope.
|
||||||
|
|
||||||
|
| Stage | Completed outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| 12 | Corrected reached-path bookkeeping across metadata, app results, batch items, and CLI summaries. |
|
||||||
|
| 13 | Hardened Promptkit-era durable state validation and restored v1/v2 state coverage. |
|
||||||
|
| 14 | Recorded every downstream path reached after completed prompt execution. |
|
||||||
|
| 15 | Restored assembled single-report behavioral and failure coverage. |
|
||||||
|
| 16 | Simplified prompt-generation orchestration while preserving behavior. |
|
||||||
|
| 17 | Restored assembled batch, planning, artifact, and notification coverage. |
|
||||||
|
| 18 | Restored supported CLI, summary, safety, and historical inspection coverage. |
|
||||||
|
| 19 | Reconciled canonical documentation and passed the complete repository verification gate. |
|
||||||
|
|
||||||
|
## Stage 12: Correct Reached-Artifact Bookkeeping
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Make metadata, app results, batch items, and CLI summaries truthful at every
|
||||||
|
failure boundary: a nonblank path means that artifact was successfully
|
||||||
|
created.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Change `state.BuildPromptMetadataFromBriefingMetadata` so it initializes
|
||||||
|
identity, schema, metadata destination, and only artifacts already saved at
|
||||||
|
the call site. It must not prepopulate raw-output, normalized-text,
|
||||||
|
render-context, managed-report, preparation, execution, notification, or
|
||||||
|
output-copy paths.
|
||||||
|
2. In `generatePromptReport`, assign each metadata and `ReportResult` path
|
||||||
|
immediately after that artifact write succeeds and before attempting the
|
||||||
|
next write. In particular:
|
||||||
|
|
||||||
|
- do not initialize `ReportResult.ReportPath` from `Store.Paths`;
|
||||||
|
- record a saved failed-preparation receipt in the result before saving
|
||||||
|
metadata;
|
||||||
|
- record a saved failed or completed execution receipt before saving
|
||||||
|
metadata;
|
||||||
|
- retain raw, normalized, context, report, copy, and notification paths
|
||||||
|
when a later step fails; and
|
||||||
|
- keep `MetadataPath` unchanged when a metadata rewrite fails, because the
|
||||||
|
prior successfully written metadata record remains the reached version.
|
||||||
|
|
||||||
|
3. Remove batch-item prepopulation from derived `Store.Paths` values.
|
||||||
|
`BatchReportResult` receives paths only from the returned `ReportResult` or
|
||||||
|
from a write that the batch itself successfully completed.
|
||||||
|
4. Preserve current CLI field names and omission behavior. Human and JSON
|
||||||
|
summaries must omit every unreached path.
|
||||||
|
5. Do not change artifact locations, filenames, schemas, report output, or
|
||||||
|
notification policy in this stage.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Add focused app tests for one representative report using real temporary
|
||||||
|
state plus a narrow failure-injecting store wrapper.
|
||||||
|
- Fail the next persistence step immediately after a successful preparation
|
||||||
|
receipt, execution receipt, raw output, normalized output, render context,
|
||||||
|
managed report, output copy, and notification artifact; assert that the
|
||||||
|
returned result contains every reached path and no future path.
|
||||||
|
- Include one preparation failure, one operational execution failure, and one
|
||||||
|
completed validation rejection to cover the three execution outcome shapes.
|
||||||
|
- Add batch and CLI summary assertions proving unreached paths are omitted.
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/state ./internal/app ./internal/cli
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
Every nonblank path in newly written metadata, app results, batch items, and
|
||||||
|
CLI summaries names an artifact that exists. Every safe artifact successfully
|
||||||
|
written before a later failure remains discoverable from the returned partial
|
||||||
|
result.
|
||||||
|
|
||||||
|
## Stage 13: Harden Durable State Contracts And Restore State Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Make the v1/v2 wire boundary and Promptkit-era artifact validation explicit,
|
||||||
|
strict, and durably tested.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Strengthen `PromptPreparationArtifact.Validate`:
|
||||||
|
|
||||||
|
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
|
||||||
|
data-package path, nonzero start/end times, nonnegative duration, and an
|
||||||
|
end not earlier than the start;
|
||||||
|
- for success, require preparation provenance, prohibit an error, and
|
||||||
|
require its prompt ID/version and data-package path to match the top-level
|
||||||
|
artifact;
|
||||||
|
- for failure, require a classified bounded error and prohibit fabricated
|
||||||
|
preparation provenance.
|
||||||
|
|
||||||
|
2. Strengthen `PromptExecutionArtifact.Validate`:
|
||||||
|
|
||||||
|
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
|
||||||
|
nonzero start/end times, nonnegative duration, and an end not earlier than
|
||||||
|
the start;
|
||||||
|
- for success and validation rejection, require provenance and completed
|
||||||
|
validation, prohibit an operational error, and require the provenance
|
||||||
|
prompt ID/version to match the artifact;
|
||||||
|
- do not compare the provenance RunID with the Weatherreporter RunID because
|
||||||
|
the provenance value is Promptkit's run identity;
|
||||||
|
- for operational failure, require a classified bounded error and prohibit
|
||||||
|
invented provenance or completed validation.
|
||||||
|
|
||||||
|
3. Validate required provenance fields for completed executions, including
|
||||||
|
Promptkit RunID, prompt and rendered hashes, selected profile/backend/model,
|
||||||
|
and data-package path. Permit usage counters and generated hash to be zero
|
||||||
|
when the provider legitimately reports no value.
|
||||||
|
4. Restore focused filesystem and metadata tests for:
|
||||||
|
|
||||||
|
- exact v2 paths and filenames;
|
||||||
|
- preparation/execution round trips and required fields;
|
||||||
|
- metadata v2 round trips without legacy aliases;
|
||||||
|
- v1 decoding, normalized internal aliases, and v1-preserving re-marshaling;
|
||||||
|
- unknown schema rejection;
|
||||||
|
- report listing, RunID lookup, source/module/data-package inspection, and
|
||||||
|
retained v1 behavior for historical report IDs;
|
||||||
|
- atomic writes and unsafe workspace/path rejection; and
|
||||||
|
- prior-snapshot behavior for the four supported report IDs.
|
||||||
|
|
||||||
|
5. Adapt useful tests from the deleted filesystem suite rather than recreating
|
||||||
|
redundant low-value cases. Do not restore Scriptorium writes or retired
|
||||||
|
report behavior.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/state ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The state package rejects incomplete or contradictory Promptkit-era artifacts,
|
||||||
|
reads historical v1 records, writes only valid v2 records, and has focused
|
||||||
|
offline coverage for its durable compatibility and filesystem contracts.
|
||||||
|
|
||||||
|
## Stage 14: Complete Execution-Artifact Path Tracking
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Make `PromptExecutionArtifact.Paths` accurately record every downstream
|
||||||
|
artifact reached after a completed Promptkit run.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Treat the execution artifact as an atomically updated durable record of the
|
||||||
|
completed Promptkit execution and subsequent artifact destinations. Its
|
||||||
|
status, provenance, validation, usage, and timing remain the provider-run
|
||||||
|
outcome; later application failures do not change a successful Promptkit
|
||||||
|
status into an execution failure.
|
||||||
|
2. Save the initial execution artifact after raw output is persisted, with
|
||||||
|
`RawOutputPath` populated.
|
||||||
|
3. After each later successful write, update and atomically resave the same
|
||||||
|
execution artifact with the corresponding reached path:
|
||||||
|
|
||||||
|
- normalized generated text;
|
||||||
|
- render context;
|
||||||
|
- managed Markdown report;
|
||||||
|
- an explicitly requested extra output copy, only after the copy succeeds;
|
||||||
|
and
|
||||||
|
- a Distributor notification artifact, including a persisted failure or
|
||||||
|
status artifact when notification produced one.
|
||||||
|
|
||||||
|
4. Keep metadata and execution-artifact path values consistent after every
|
||||||
|
successful checkpoint. Save the execution artifact before metadata so a
|
||||||
|
metadata failure does not erase knowledge of a reached downstream artifact.
|
||||||
|
Failure to update the execution artifact is terminal and returns a partial
|
||||||
|
result containing the downstream artifact that was already written.
|
||||||
|
5. Refactor finalization return values only as needed to tell the orchestration
|
||||||
|
layer which copy and notification paths were actually written. Distributor
|
||||||
|
must continue uploading only the managed Markdown report.
|
||||||
|
6. A validation-rejected execution ends after raw output and therefore records
|
||||||
|
only the raw-output path. An operational execution failure has no completed
|
||||||
|
provenance and records only safe paths reached before that failure.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Add table-driven execution-artifact lifecycle tests for success and every
|
||||||
|
downstream failure point.
|
||||||
|
- Load the persisted execution artifact after normalized-text, context,
|
||||||
|
template, copy, metadata, and notification failures and assert its status and
|
||||||
|
exact reached paths.
|
||||||
|
- Assert that execution artifacts never contain generated bodies, rendered
|
||||||
|
prompts, schemas, endpoints, parameters, or credentials.
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/state ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
For every completed Promptkit run, its execution artifact contains exactly the
|
||||||
|
safe downstream paths reached by the workflow and remains semantically correct
|
||||||
|
when a later application stage fails.
|
||||||
|
|
||||||
|
## Stage 15: Restore Single-Report Behavioral Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Restore the risk-based application coverage removed during final cutover and
|
||||||
|
prove the complete single-report Promptkit workflow through project-owned
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Reintroduce a focused app test harness using real state, prompt-input,
|
||||||
|
generated-text validation, render contexts, and templates with deterministic
|
||||||
|
collector, executor, notifier, clock, and filesystem boundaries.
|
||||||
|
2. Add representative successful workflows for Daily, Today, Tomorrow, and
|
||||||
|
Hourly. Verify report identity, exact prompt version, one collection, exact
|
||||||
|
persisted YAML bytes passed to the executor, expected template output,
|
||||||
|
optional copy behavior, and managed-report notification source.
|
||||||
|
3. Cover the required failure matrix:
|
||||||
|
|
||||||
|
- inspection and missing credentials before collection;
|
||||||
|
- preparation failure and callback persistence failure before provider work;
|
||||||
|
- execution-time credential disappearance;
|
||||||
|
- capacity rejection without retry;
|
||||||
|
- cancellation and deadline;
|
||||||
|
- generation and operational-validation failure;
|
||||||
|
- completed Promptkit schema rejection with retained raw output;
|
||||||
|
- generated-text decode/domain rejection;
|
||||||
|
- render-context and template failure;
|
||||||
|
- output-copy failure; and
|
||||||
|
- notification failure.
|
||||||
|
|
||||||
|
4. Verify preparation persistence precedes provider execution, debug-write
|
||||||
|
failure prevents provider execution, and execution-debug failure preserves
|
||||||
|
previously reached normal and debug artifacts.
|
||||||
|
5. Verify Recent Changes, prior-snapshot selection, output naming, and
|
||||||
|
Distributor template values for all four retained reports.
|
||||||
|
6. Adapt useful tests from the deleted app suite. Omit Scriptorium mechanics,
|
||||||
|
subprocess interaction assertions, and retired report products.
|
||||||
|
7. Fix defects exposed by these tests only when the expected behavior is
|
||||||
|
already decided by the roadmap or canonical policy. Record any new product
|
||||||
|
question instead of silently choosing it.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app
|
||||||
|
go test -race ./internal/app ./internal/adapters/promptkit
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The single-report workflow has deterministic behavioral coverage for all four
|
||||||
|
reports, all consequential failure stages, artifact ordering, partial results,
|
||||||
|
debug isolation, output copying, and notification behavior.
|
||||||
|
|
||||||
|
## Stage 16: Refactor Prompt Generation Orchestration
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Reduce the complexity and duplicated persistence logic in
|
||||||
|
`generatePromptReport` without changing observable behavior.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Use the Stage 12–15 tests as the refactoring safety boundary. Do not weaken
|
||||||
|
assertions to accommodate structural changes.
|
||||||
|
2. Split the current orchestration into small app-owned operations with clear
|
||||||
|
inputs and outcomes for:
|
||||||
|
|
||||||
|
- deterministic input and initial state construction;
|
||||||
|
- preparation callback persistence;
|
||||||
|
- preparation-failure persistence;
|
||||||
|
- operational-execution-failure persistence;
|
||||||
|
- completed execution and raw-output persistence;
|
||||||
|
- normalized text and render-context persistence;
|
||||||
|
- managed report, optional copy, metadata, and notification finalization;
|
||||||
|
and
|
||||||
|
- reached-path updates shared by success and failure paths.
|
||||||
|
|
||||||
|
3. Keep workflow order visible in one coordinator. Do not introduce a generic
|
||||||
|
workflow engine, hidden retry loop, provider-specific app type, or mutable
|
||||||
|
global state.
|
||||||
|
4. Centralize the repeated rule that a successful artifact write updates the
|
||||||
|
result before any following write can fail.
|
||||||
|
5. Preserve error identities, safe text, atomic writes, exact bytes, debug
|
||||||
|
ordering, partial results, and notification behavior.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w internal/app/*.go
|
||||||
|
go test ./internal/app ./internal/state ./internal/cli
|
||||||
|
go test -race ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The top-level coordinator communicates the workflow order without containing
|
||||||
|
the full persistence implementation, duplicate failure branches are reduced,
|
||||||
|
and every Stage 12–15 behavioral test passes unchanged.
|
||||||
|
|
||||||
|
## Stage 17: Restore Batch Behavioral Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Re-establish confidence that morning and evening batches preserve their
|
||||||
|
pre-migration behavior while sharing one Promptkit executor.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Add assembled batch tests proving:
|
||||||
|
|
||||||
|
- one executor factory call and one executor per CLI invocation;
|
||||||
|
- inspection of the full candidate set before collection;
|
||||||
|
- one weather collection;
|
||||||
|
- existing morning/evening planning and ordering;
|
||||||
|
- sequential execution through the shared executor;
|
||||||
|
- continuation after an independent report failure;
|
||||||
|
- no retry after capacity rejection;
|
||||||
|
- distinct identities and debug directories for multiple Daily dates; and
|
||||||
|
- exact reached paths on successful and failed batch items.
|
||||||
|
|
||||||
|
2. Restore notification coverage for disabled notification, suppressed
|
||||||
|
per-report notification, all-success batch notification, skipped
|
||||||
|
notification after report failure, and persisted notification failure/status
|
||||||
|
artifacts.
|
||||||
|
3. Restore output-directory, Today/Tomorrow naming, dynamic Daily planning,
|
||||||
|
prior-snapshot, and managed-Markdown upload-source coverage.
|
||||||
|
4. Adapt useful tests from the deleted batch portions of the app and CLI suites.
|
||||||
|
Do not restore retired report cases or Scriptorium fakes.
|
||||||
|
5. Fix only roadmap-defined batch regressions exposed by the restored tests.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app ./internal/cli
|
||||||
|
go test -race ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
Morning and evening batches are covered as assembled sequential workflows and
|
||||||
|
demonstrably preserve collection, planning, continuation, output, debug,
|
||||||
|
artifact, and notification contracts with one Promptkit executor.
|
||||||
|
|
||||||
|
## Stage 18: Restore CLI And Inspection Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Restore the user-facing command, summary, and historical inspection contracts
|
||||||
|
removed with the old root test suite.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Add parser and resolver tests for all four generate commands, both batch
|
||||||
|
commands, shared flags, report-specific date rules, malformed input,
|
||||||
|
`--llm-debug-dir`, `--quiet`, output paths, and rejection of retired report
|
||||||
|
names.
|
||||||
|
2. Add assembled CLI tests for representative successful single and batch
|
||||||
|
invocations using injected offline boundaries. Verify exactly one executor
|
||||||
|
construction per action.
|
||||||
|
3. Cover pre-run errors with no invented run summary, successful and failed
|
||||||
|
JSON summaries, quiet-mode behavior, safe human status output, partial paths,
|
||||||
|
and omission of absent notification/debug fields.
|
||||||
|
4. Restore inspection tests for report listing and v1/v2 metadata, modules,
|
||||||
|
data packages, prior snapshots, and sources. Include failed v2 runs and v1
|
||||||
|
fixtures using historical report IDs.
|
||||||
|
5. Assert that routine output never contains rendered prompts, schema bodies,
|
||||||
|
data packages, generated bodies, endpoints, full parameters, credentials, or
|
||||||
|
secret-like dependency errors.
|
||||||
|
6. Keep tests at stable CLI/app boundaries; do not restore assertions about
|
||||||
|
private parser formatting or Scriptorium subprocess mechanics.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/cli ./internal/app ./internal/state
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The supported CLI surface, summaries, quiet mode, partial failures, executor
|
||||||
|
composition, and v1/v2 inspection behavior have deterministic offline coverage.
|
||||||
|
|
||||||
|
## Stage 19: Finalize Documentation And Repository Verification
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Close the audit remediation, make roadmap lifecycle state truthful, and verify
|
||||||
|
the repository against the complete target contract.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Update `docs/roadmap/promptkit.md` from future tense and “unimplemented”
|
||||||
|
statuses to a completed roadmap record. Describe its old seven-report and
|
||||||
|
Scriptorium material explicitly as the pre-migration baseline rather than
|
||||||
|
current behavior.
|
||||||
|
2. Mark Stages 12–19 and this implementation plan complete only after their
|
||||||
|
exit gates pass. Retain the concise completed-stage history unless the
|
||||||
|
documentation policy calls for archival in the same change.
|
||||||
|
3. Review canonical architecture, app, state, CLI, Promptkit integration,
|
||||||
|
operations, troubleshooting, configuration, and testing documentation
|
||||||
|
against the corrected implementation. Update only actual current-state
|
||||||
|
discrepancies; do not duplicate the roadmap.
|
||||||
|
4. Search current-state code, tests, examples, help, and non-roadmap
|
||||||
|
documentation for stale Scriptorium terms, retired reports, old artifact
|
||||||
|
fields, speculative-path descriptions, or claims of missing Promptkit
|
||||||
|
implementation.
|
||||||
|
5. Confirm examples contain no credentials or private infrastructure values
|
||||||
|
and load through config tests.
|
||||||
|
|
||||||
|
### Final Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w <all changed Go files>
|
||||||
|
go mod tidy
|
||||||
|
go vet ./...
|
||||||
|
go test -count=1 ./...
|
||||||
|
go test -race ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify explicitly:
|
||||||
|
|
||||||
|
- `go list -m gitea.maximumdirect.net/eric/promptkit` reports `v0.4.0`;
|
||||||
|
- no committed `go.work`, `replace`, secret fixture, or live-provider test
|
||||||
|
exists;
|
||||||
|
- all four prompts inspect at exact version `1.0.0`;
|
||||||
|
- no runtime prompt requests repair attempts;
|
||||||
|
- v1 fixtures remain inspectable and new runs write only v2;
|
||||||
|
- normal artifacts and output contain no sensitive prompt/debug content;
|
||||||
|
- failed-run metadata, execution artifacts, app results, batch items, and CLI
|
||||||
|
summaries contain exactly the paths actually reached;
|
||||||
|
- help exposes only Daily, Today, Tomorrow, Hourly, morning, and evening; and
|
||||||
|
- managed Markdown remains the only Distributor upload source.
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
Every migration and audit-remediation criterion is demonstrably satisfied,
|
||||||
|
the restored tests protect the consequential contracts, canonical
|
||||||
|
documentation describes the corrected implementation, and both roadmap
|
||||||
|
documents are marked complete.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The roadmap and this completed plan record the decisions used for the
|
||||||
|
audit remediation.
|
||||||
@@ -1,204 +1,353 @@
|
|||||||
# Promptkit Migration Roadmap
|
# Promptkit Migration Roadmap
|
||||||
|
|
||||||
Status: Accepted migration policy; the migration itself is unimplemented.
|
Status: Completed roadmap record.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This roadmap defines the scope and desired end state for replacing the
|
This roadmap records the scope, decisions, and completed outcome of replacing
|
||||||
external Scriptorium CLI integration with the Promptkit Go library. The
|
the external Scriptorium CLI integration with Promptkit. Canonical
|
||||||
migration is not yet implemented. Current Scriptorium behavior remains
|
documentation outside `docs/roadmap/` owns the implemented behavior.
|
||||||
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
|
## Pre-Migration Baseline
|
||||||
current code to this target state. That plan should reference this roadmap
|
|
||||||
rather than redefine its architectural decisions or scope.
|
|
||||||
|
|
||||||
## Desired End State
|
Status: Historical migration input.
|
||||||
|
|
||||||
Status: Accepted target state; unimplemented.
|
Before the migration, Weatherreporter exposed seven report definitions, but
|
||||||
|
only four had complete prompt-backed report implementations:
|
||||||
|
|
||||||
Weatherreporter uses a pinned released version of
|
- Daily Report: `weather.daily_generated_text`
|
||||||
`gitea.maximumdirect.net/eric/promptkit` as its in-process prompt preparation
|
- Today Report: `weather.today_generated_text`
|
||||||
and LLM execution engine. The `scriptorium` executable, subprocess adapter,
|
- Tomorrow Report: `weather.tomorrow_generated_text`
|
||||||
configuration, runtime dependency, and integration documentation have been
|
- Hourly Report: `weather.hourly_generated_text`
|
||||||
removed.
|
|
||||||
|
|
||||||
The migration does not change weatherreporter's fundamental product behavior.
|
The three-day, weekend, and storm commands and registry definitions had no
|
||||||
Weather selection, forecast derivation, report periods, module construction,
|
corresponding Scriptorium prompt or schema and never formed complete
|
||||||
|
operational report products. The `weather.daily_report` Scriptorium prompt was
|
||||||
|
legacy source material and was not selected by the registry.
|
||||||
|
|
||||||
|
The Scriptorium source corpus was retained temporarily under
|
||||||
|
`docs/roadmap/scriptorium/` as migration input. It contained the four
|
||||||
|
operational generated-text prompt definitions, their referenced content,
|
||||||
|
private response schemas, shared instructions, and the unused legacy Daily
|
||||||
|
Markdown prompt. The temporary corpus was removed after the runtime assets
|
||||||
|
were reconciled and embedded.
|
||||||
|
|
||||||
|
## Implemented End State
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
Weatherreporter pins
|
||||||
|
`gitea.maximumdirect.net/eric/promptkit` at `v0.4.0` and uses it as the
|
||||||
|
in-process engine for prompt inspection, prepared execution, provider calls,
|
||||||
|
and first-pass output validation.
|
||||||
|
|
||||||
|
The `scriptorium` executable, subprocess adapter, configuration, runtime
|
||||||
|
dependency, direct-Markdown execution path, and integration documentation have
|
||||||
|
been removed. The four operational reports continue to use structured
|
||||||
|
generated text followed by weatherreporter-owned validation and Markdown
|
||||||
|
templates.
|
||||||
|
|
||||||
|
The unfinished three-day, weekend, and storm reports are not implemented as
|
||||||
|
part of this migration. Their incomplete CLI, registry, documentation, and
|
||||||
|
generation declarations are removed from the implemented surface before the
|
||||||
|
migration is considered complete. Any future implementation of those products
|
||||||
|
requires separate roadmap scope, prompt and schema design, tests, and
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Weather selection, forecast derivation, valid periods, module construction,
|
||||||
Recent Changes, generated-text interpretation, Markdown templates, durable
|
Recent Changes, generated-text interpretation, Markdown templates, durable
|
||||||
state, inspection, output copies, and distributor notification remain owned by
|
state, inspection, output copies, and Distributor notification remain owned by
|
||||||
weatherreporter.
|
weatherreporter.
|
||||||
|
|
||||||
All report prompts and private response schemas are versioned application
|
The four report prompts and private response schemas are versioned embedded
|
||||||
assets. Operators may configure Promptkit execution profiles without replacing
|
application assets. Operators configure Promptkit profiles without replacing
|
||||||
the report-owned prompt and schema corpus. One Promptkit engine is constructed
|
the report-owned corpus. One Promptkit engine is constructed per CLI
|
||||||
per CLI invocation and shared by every report in that invocation, including
|
invocation and shared by every report in that invocation, including all
|
||||||
all reports in a morning or evening batch.
|
reports in a morning or evening batch.
|
||||||
|
|
||||||
Promptkit is isolated behind a weatherreporter-owned prompt execution contract.
|
Promptkit is isolated behind a weatherreporter-owned execution contract.
|
||||||
Promptkit request, result, validation, error, profile, backend, and provider
|
Promptkit request, result, validation, error, profile, backend, and provider
|
||||||
types do not leak into application orchestration, report definitions, domain
|
types do not leak into application orchestration, report definitions, domain
|
||||||
packages, CLI summaries, state contracts, or distributor behavior.
|
packages, CLI summaries, durable state contracts, or Distributor behavior.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
Status: Accepted migration scope; unimplemented.
|
Status: Completed migration outcomes.
|
||||||
|
|
||||||
- Remove the runtime dependency on the `scriptorium` executable.
|
- Removed the Scriptorium runtime dependency and subprocess boundary.
|
||||||
- Replace shell-free subprocess orchestration with typed in-process Promptkit
|
- Migrated the four operational report prompts to Promptkit `v0.4.0`.
|
||||||
preparation and execution.
|
- Used prepared execution to persist preparation provenance before provider work
|
||||||
- Preserve the seven report definitions and their existing prompt IDs.
|
while executing the exact frozen snapshot.
|
||||||
- Preserve both direct-Markdown and generated-text-template report workflows.
|
- Validated report prompt and profile selections before weather collection when
|
||||||
- Preserve deterministic module snapshots and structured Recent Changes.
|
the required information is available.
|
||||||
- Preserve context cancellation, actionable errors, secret redaction, and
|
- Preserved deterministic module snapshots and structured Recent Changes.
|
||||||
|
- Preserved generated-text domain validation and repository-owned Markdown
|
||||||
|
rendering.
|
||||||
|
- Preserved context cancellation, actionable errors, secret redaction, and
|
||||||
inspectable failures.
|
inspectable failures.
|
||||||
- Improve durable prompt provenance with prompt, input, profile, model,
|
- Improved durable prompt provenance with prompt, input, profile, model,
|
||||||
validation, usage, and timing metadata.
|
validation, usage, and timing metadata.
|
||||||
- Keep content-rich prompt and response diagnostics separate from routine
|
- Kept content-rich prompt and response diagnostics separate from routine
|
||||||
metadata and CLI output.
|
metadata and CLI output.
|
||||||
- Keep tests offline and deterministic through injected Promptkit model
|
- Kept tests offline and deterministic through injected Promptkit model
|
||||||
clients and fixtures.
|
clients and fixtures.
|
||||||
|
- Removed incomplete report declarations from the implemented product surface
|
||||||
|
rather than creating new report products during an integration migration.
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
Status: Accepted migration scope; unimplemented.
|
Status: Completed migration constraints.
|
||||||
|
|
||||||
The migration will not:
|
The completed migration did not:
|
||||||
|
|
||||||
|
- create prompts, schemas, templates, or completed products for three-day,
|
||||||
|
weekend, or storm reports;
|
||||||
|
- preserve the unused `weather.daily_report` legacy Markdown prompt as an
|
||||||
|
active runtime asset;
|
||||||
|
- preserve a direct-Markdown LLM generation mode;
|
||||||
- move meteorological selection, derivation, thresholds, or comparison logic
|
- move meteorological selection, derivation, thresholds, or comparison logic
|
||||||
into prompts or Promptkit;
|
into prompts or Promptkit;
|
||||||
- send raw unbounded Weather API responses to the model;
|
- send raw unbounded Weather API responses to the model;
|
||||||
- replace weatherreporter's generated-text domain validation or Markdown
|
- replace weatherreporter's generated-text domain validation or Markdown
|
||||||
template rendering;
|
template rendering;
|
||||||
- add a general workflow engine, provider plugin system, or arbitrary backend
|
- add a general workflow engine, provider plugin system, or arbitrary backend
|
||||||
registry to weatherreporter;
|
registry;
|
||||||
- add automatic provider, validation, or capacity retries;
|
- add automatic provider, validation, repair, or capacity retries;
|
||||||
- add concurrent report generation to the existing sequential batch workflow;
|
- add concurrent report generation to the sequential batch workflow;
|
||||||
- expose Promptkit types as a weatherreporter component contract;
|
- expose Promptkit types as weatherreporter contracts;
|
||||||
- keep a production-selectable Scriptorium/Promptkit dual-run mode; or
|
- keep a production-selectable Scriptorium/Promptkit dual-run mode;
|
||||||
|
- require Promptkit eager source validation, structured generation errors, or
|
||||||
|
semantic execution-target fingerprints; or
|
||||||
- use an unpublished Promptkit commit, committed Go workspace, or committed
|
- use an unpublished Promptkit commit, committed Go workspace, or committed
|
||||||
local module replacement.
|
local module replacement.
|
||||||
|
|
||||||
## Locked Decisions
|
## Locked Decisions
|
||||||
|
|
||||||
Status: Accepted decisions for the unimplemented migration.
|
Status: Implemented migration decisions.
|
||||||
|
|
||||||
### Dependency And Versioning
|
### Dependency And Upgrade Boundary
|
||||||
|
|
||||||
- The initial integration will pin Promptkit `v0.3.0`.
|
- The migration pins the tagged Promptkit `v0.4.0` release.
|
||||||
- Coordinated local development may temporarily use the sibling Promptkit
|
- Coordinated local development may temporarily use the sibling Promptkit
|
||||||
checkout, but committed module metadata must reference the tagged release.
|
checkout, but committed module metadata must reference the tagged release.
|
||||||
- A future Promptkit upgrade requires an explicit review of the public engine,
|
- The adapter relies on the public root Promptkit package only.
|
||||||
prompt/profile/schema formats, error identities, validation behavior, and
|
- A future Promptkit upgrade requires explicit review of prepared-execution
|
||||||
outbound provider contract used by weatherreporter.
|
lifecycle, prompt and profile inspection, prompt/profile/schema formats,
|
||||||
|
error identities, validation behavior, capacity behavior, and the outbound
|
||||||
|
provider contract.
|
||||||
|
- Promptkit's deferred eager source validation, structured generation errors,
|
||||||
|
and semantic execution-target fingerprints do not block this migration.
|
||||||
|
|
||||||
|
### Operational Report Scope
|
||||||
|
|
||||||
|
- The migration preserves these prompt IDs:
|
||||||
|
`weather.daily_generated_text`, `weather.today_generated_text`,
|
||||||
|
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`.
|
||||||
|
- Each operational report definition selects the exact embedded prompt version
|
||||||
|
`1.0.0`; execution does not rely on ambiguous single-version lookup.
|
||||||
|
- Morning and evening batch membership remains based on Today, Tomorrow, and
|
||||||
|
eligible future Daily reports.
|
||||||
|
- Three-day, weekend, and storm are removed from current CLI help, parsing,
|
||||||
|
report registry membership, tests that claim implemented generation, and
|
||||||
|
non-roadmap documentation.
|
||||||
|
- The future product concepts may remain under `docs/roadmap/`, but migration
|
||||||
|
verification does not invent outputs or compare nonexistent prompts.
|
||||||
|
|
||||||
### Application Boundary
|
### Application Boundary
|
||||||
|
|
||||||
- Promptkit remains an adapter boundary even though it runs in process.
|
- Promptkit remains an adapter boundary even though it runs in process.
|
||||||
- A weatherreporter-owned contract will represent preparation, execution,
|
- A weatherreporter-owned contract represents prompt identity, preparation,
|
||||||
output formats, validation, usage, provenance, and neutral error categories.
|
execution, output, validation, usage, provenance, and neutral error
|
||||||
- The Promptkit adapter will map public Promptkit values into that contract at
|
categories.
|
||||||
the boundary.
|
- The Promptkit adapter maps public Promptkit values into that contract.
|
||||||
- App orchestration and test fakes will depend on the weatherreporter contract,
|
- App orchestration and test fakes depend on the project-owned contract, not
|
||||||
not on Promptkit.
|
Promptkit.
|
||||||
- Existing Scriptorium-specific generation mode names will be replaced with
|
- Scriptorium-specific request, result, error, and generation-mode types are
|
||||||
provider-neutral names.
|
removed rather than renamed and retained.
|
||||||
|
|
||||||
### Prompt And Schema Ownership
|
### Prompt And Schema Ownership
|
||||||
|
|
||||||
- Weatherreporter will embed all report prompt definitions, prompt content,
|
- Weatherreporter embeds the four operational prompt definitions, referenced
|
||||||
and private response schemas.
|
prompt content, shared prompt content, and private response schemas.
|
||||||
- Prompt assets will remain separate files rather than inline Go strings.
|
- Assets remain separate files rather than inline Go strings.
|
||||||
- The current Scriptorium prompt corpus will be retrieved before the
|
- The temporary corpus under `docs/roadmap/scriptorium/` is migration source
|
||||||
implementation stage that establishes the embedded Promptkit assets.
|
material, not the final runtime location.
|
||||||
- The retrieved corpus will be reviewed and converted to the pinned Promptkit
|
- Weatherreporter's existing generated-text domain types, schemas, and
|
||||||
format without changing report intent or prompt IDs.
|
templates remain the canonical application contract. Imported Scriptorium
|
||||||
- The four existing generated-text prompt fragments and schemas under
|
assets are reconciled with that contract rather than copied blindly or kept
|
||||||
`internal/reporttemplate` will be reconciled with that corpus rather than
|
as duplicate runtime schemas.
|
||||||
duplicated.
|
- The imported Daily schema's incorrect Today `$id` and title are corrected.
|
||||||
- Direct-Markdown prompt assets for the three-day, weekend, and storm reports
|
- `confidence` is handled consistently across each prompt, provider-facing
|
||||||
will become weatherreporter-owned assets.
|
schema, generated-text domain type, and template. The existing optional
|
||||||
- Weatherreporter needs one centralized embedded prompt/schema source; it does
|
weatherreporter field remains supported unless a separate domain decision
|
||||||
not need Notarius's multi-module asset-flattening registry.
|
removes it.
|
||||||
|
- Prompt input metadata identifies the serialized data package as YAML rather
|
||||||
|
than JSON.
|
||||||
|
- Imported `pipeline-weather/...` schema paths are replaced with paths valid
|
||||||
|
inside the embedded Promptkit schema source.
|
||||||
|
- Imported `repair_attempts: 2` values are removed or set to zero. The
|
||||||
|
migration does not rely on Promptkit's internal-only repair capability.
|
||||||
|
- The unused `weather.daily_report` prompt is not promoted into runtime assets.
|
||||||
|
- One centralized embedded prompt/schema source is sufficient; Weatherreporter
|
||||||
|
does not need Notarius's multi-module asset-flattening registry.
|
||||||
|
|
||||||
### Profiles, Backends, And Credentials
|
### Profiles, Backends, And Credentials
|
||||||
|
|
||||||
- Execution profiles remain operator-configurable rather than embedded report
|
- Execution profiles remain operator-configurable rather than embedded report
|
||||||
policy.
|
policy.
|
||||||
- Configuration will support at most one external profile source: a profile
|
- Each embedded operational prompt declares Promptkit's built-in
|
||||||
directory or a single profile file.
|
`gemini-flash-latest` profile as its default.
|
||||||
- Prompt definitions may provide their normal default profile, while
|
- `gemini-flash-latest` is intentionally a moving model alias. The execution
|
||||||
weatherreporter may support an explicit configured profile selection.
|
record captures the effective model identity, but operators who require a
|
||||||
|
pinned model must select an explicit external profile.
|
||||||
|
- Configuration supports at most one external profile source:
|
||||||
|
`promptkit.profile_file` or `promptkit.profile_dir`. The two fields are
|
||||||
|
mutually exclusive.
|
||||||
|
- A nonblank `promptkit.profile` is the explicit request profile for every
|
||||||
|
report in the invocation and takes precedence over each prompt's
|
||||||
|
`default_profile`. A blank value uses the prompt default.
|
||||||
|
- Promptkit's normal profile-source precedence remains intact: an external
|
||||||
|
matching profile takes precedence over an embedded built-in profile, and an
|
||||||
|
invalid matching external profile is an error rather than a reason to fall
|
||||||
|
back.
|
||||||
|
- Weatherreporter exposes Promptkit's conventional `local` backend through the
|
||||||
|
narrow `promptkit.local.endpoint` and
|
||||||
|
`promptkit.local.concurrency_limit` configuration fields. It does not expose
|
||||||
|
arbitrary backend registration.
|
||||||
|
- A configured local endpoint registers the engine-scoped `local` backend. An
|
||||||
|
operator-supplied external profile selects it with `backend: local` and owns
|
||||||
|
the model-specific settings; Weatherreporter does not invent a local model
|
||||||
|
profile.
|
||||||
|
- Local concurrency defaults to one. A value of zero means unlimited, matching
|
||||||
|
Promptkit, and a negative value is invalid. Queue capacity and general
|
||||||
|
backend parameters are not exposed.
|
||||||
- Credential values remain in environment variables or file-backed
|
- Credential values remain in environment variables or file-backed
|
||||||
environment secrets. Configuration contains only credential source names.
|
environment secrets. Configuration contains only credential source names.
|
||||||
- Provider credentials must not appear in logs, errors, CLI output, durable
|
- Provider credentials never appear in logs, errors, CLI output, durable
|
||||||
metadata, preparation artifacts, execution artifacts, or debug summaries.
|
metadata, preparation records, execution records, or debug summaries.
|
||||||
- Weatherreporter will not expose Promptkit's general backend registry as
|
- Promptkit `InspectProfile` reports structural target and credential
|
||||||
arbitrary application configuration.
|
requirements; Weatherreporter owns policy for checking configured
|
||||||
|
environment availability.
|
||||||
|
- Promptkit revalidates environment credentials at `RunPrepared`; a successful
|
||||||
|
preparation does not promise that execution-time credentials remain
|
||||||
|
available.
|
||||||
|
|
||||||
### Engine Lifetime
|
### Configuration Contract
|
||||||
|
|
||||||
- One Promptkit engine will be constructed per CLI invocation at the
|
The replacement configuration surface is:
|
||||||
application composition boundary.
|
|
||||||
- Single-report generation will use that engine for preparation and execution.
|
```yaml
|
||||||
- Morning and evening batches will share the same engine across every planned
|
promptkit:
|
||||||
report.
|
profile: ""
|
||||||
- Per-report orchestration will not construct its own default Promptkit engine.
|
profile_file: ""
|
||||||
- Promptkit backend capacity state and HTTP transport will therefore be shared
|
profile_dir: ""
|
||||||
consistently for the invocation.
|
timeout: 2m
|
||||||
|
|
||||||
|
local:
|
||||||
|
endpoint: ""
|
||||||
|
concurrency_limit: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `timeout` remains the transport-wide provider-call safety cap.
|
||||||
|
- A blank local endpoint leaves the conventional local backend unregistered.
|
||||||
|
- Scriptorium's `binary`, `config_path`, and `extra_args` settings have no
|
||||||
|
Promptkit equivalents and are removed.
|
||||||
|
- Configuration validation rejects simultaneous `profile_file` and
|
||||||
|
`profile_dir` values, invalid local endpoints, negative concurrency, and
|
||||||
|
selected profiles that cannot resolve their backend.
|
||||||
|
|
||||||
|
### Engine Construction And Inspection
|
||||||
|
|
||||||
|
- One Promptkit engine is constructed per CLI invocation at the application
|
||||||
|
composition boundary.
|
||||||
|
- Single-report generation and every report in a batch use that same engine.
|
||||||
|
- Per-report orchestration does not construct a default engine.
|
||||||
|
- Promptkit backend capacity state and HTTP transport are shared consistently
|
||||||
|
for the invocation.
|
||||||
|
- Before collection, `InspectPrompt` checks every selected report's exact ID
|
||||||
|
and version, declared `data_package` input, default-profile metadata, prompt
|
||||||
|
hash availability, and declared output contract.
|
||||||
|
- `InspectPrompt` is a point-in-time structural check. It does not load a JSON
|
||||||
|
Schema, resolve a profile, or freeze later execution.
|
||||||
|
- Explicit profile overrides and relevant prompt defaults are checked with
|
||||||
|
`InspectProfile` before collection when application policy requires them.
|
||||||
|
- `InspectProfile` is also point-in-time and does not check credential values.
|
||||||
|
- Successful `PrepareExecution`, not inspection, is the per-run authority for
|
||||||
|
loaded schema, rendered content, frozen inputs, effective settings, and
|
||||||
|
durable execution provenance.
|
||||||
|
|
||||||
### Prompt Input
|
### Prompt Input
|
||||||
|
|
||||||
- Promptkit will continue to receive the curated `data_package` produced by
|
- Promptkit receives only the curated `data_package` produced by
|
||||||
`internal/promptinput`.
|
`internal/promptinput`.
|
||||||
- Weatherreporter will serialize the data package once, atomically persist
|
- Weatherreporter serializes the package once, atomically persists those exact
|
||||||
those exact bytes, and supply the same bytes as a Promptkit inline artifact.
|
bytes, and supplies the same bytes with a Promptkit inline artifact.
|
||||||
- The managed data-package path may be supplied as non-secret artifact
|
- The managed data-package path may be supplied as non-secret provenance
|
||||||
provenance.
|
through the inline artifact URI.
|
||||||
- Weatherreporter will not delegate unrestricted path loading to Promptkit's
|
- Weatherreporter does not delegate unrestricted path loading to Promptkit's
|
||||||
default file artifact reader.
|
default file artifact reader.
|
||||||
- The same immutable Promptkit request will be used for preparation and
|
- Prompt inspection and adapter tests verify that `data_package` is required
|
||||||
execution so the preflight and run inputs cannot diverge.
|
and declared with the chosen YAML media type.
|
||||||
|
|
||||||
### Preparation And Execution
|
### Prepared Execution
|
||||||
|
|
||||||
- Promptkit `Prepare` replaces the current Scriptorium render preflight.
|
- `Engine.PrepareExecution` replaces Scriptorium render preflight.
|
||||||
- Promptkit `Run` performs both Markdown and structured generated-text
|
- Weatherreporter obtains `PreparedExecution.Details`, maps a safe subset into
|
||||||
execution.
|
its own preparation record, and persists that record before calling
|
||||||
- Promptkit basic validation will be used where appropriate for direct
|
`Engine.RunPrepared`.
|
||||||
Markdown output.
|
- `RunPrepared` executes the frozen prompt, profile, schema, inputs, rendered
|
||||||
- Promptkit JSON Schema validation provides the provider-facing and first
|
messages, target, and validation resources retained by the handle.
|
||||||
structured-output check for generated-text reports.
|
- Every acquired handle is followed immediately by `defer handle.Discard()`.
|
||||||
|
Discard is safe after execution and releases unused private execution state.
|
||||||
|
- Handles remain adapter-local, engine-bound, one-shot, in-process values.
|
||||||
|
They are never serialized, persisted, copied into app contracts, or treated
|
||||||
|
as restartable jobs.
|
||||||
|
- Preparation and execution use independent contexts. Execution receives the
|
||||||
|
active report workflow context.
|
||||||
|
- Capacity is not reserved during preparation. Capacity rejection can
|
||||||
|
therefore occur after a preparation record has been persisted.
|
||||||
|
- `RunPrepared` consumes the handle on success and every operational failure.
|
||||||
|
- Preparation details remain available from the adapter after execution or
|
||||||
|
discard, but rendered messages are not copied into routine durable state.
|
||||||
|
- Promptkit execution timing excludes preparation and consumer-held delay.
|
||||||
|
Weatherreporter records preparation timing and execution timing separately.
|
||||||
|
|
||||||
|
### Execution And Validation
|
||||||
|
|
||||||
|
- All four operational reports use Promptkit JSON Schema output validation.
|
||||||
|
- A completed Promptkit validation rejection returns a `RunResult`; the
|
||||||
|
adapter retains raw output and bounded validation details before failing the
|
||||||
|
report.
|
||||||
|
- An operational generation or validation error returns no partial
|
||||||
|
`RunResult`.
|
||||||
- Weatherreporter's `internal/generatedtext` validation remains the final
|
- Weatherreporter's `internal/generatedtext` validation remains the final
|
||||||
report-specific domain boundary.
|
report-specific decode and domain boundary.
|
||||||
- Weatherreporter's `internal/reporttemplate` remains responsible for
|
- Weatherreporter's `internal/reporttemplate` remains responsible for managed
|
||||||
generated-text Markdown rendering.
|
Markdown rendering.
|
||||||
- Weatherreporter will atomically persist Promptkit output rather than asking
|
- Weatherreporter atomically persists Promptkit raw output and later artifacts
|
||||||
the dependency to write managed report files.
|
rather than asking Promptkit to choose managed filesystem paths.
|
||||||
- The migration will not rely on Promptkit output repair. Promptkit v0.3.0's
|
- No Promptkit output-repair behavior is assumed or requested.
|
||||||
public engine validates in a single pass even when a prompt declares repair
|
|
||||||
attempts.
|
|
||||||
|
|
||||||
## Durable Artifacts And Observability
|
## Durable Artifacts And Observability
|
||||||
|
|
||||||
Status: Accepted design constraints; unimplemented.
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
Routine durable artifacts should retain useful non-secret provenance without
|
Routine durable state retains useful non-secret provenance without persisting
|
||||||
persisting full rendered prompts by default.
|
full rendered prompts.
|
||||||
|
|
||||||
The preparation record should contain:
|
The preparation record contains:
|
||||||
|
|
||||||
- prompt ID and version;
|
- prompt ID and exact version;
|
||||||
- prompt definition hash;
|
- prompt definition hash;
|
||||||
- rendered prompt hash;
|
- rendered prompt hash;
|
||||||
- input hashes;
|
- input hashes;
|
||||||
- selected profile and backend identity;
|
- selected profile and backend identity;
|
||||||
- effective model identity;
|
- effective model identity;
|
||||||
- output contract summary; and
|
- output contract summary;
|
||||||
- preparation timing.
|
- preparation start, end, and duration; and
|
||||||
|
- the path of the exact persisted data package.
|
||||||
|
|
||||||
The execution record and run metadata should contain, when available:
|
The execution record and run metadata contain, when available:
|
||||||
|
|
||||||
- Promptkit run ID;
|
- Promptkit run ID;
|
||||||
- prompt ID, version, and hashes;
|
- prompt ID, version, and hashes;
|
||||||
@@ -206,122 +355,162 @@ The execution record and run metadata should contain, when available:
|
|||||||
- selected profile, backend, and model identity;
|
- selected profile, backend, and model identity;
|
||||||
- generated-content hash;
|
- generated-content hash;
|
||||||
- token usage;
|
- token usage;
|
||||||
- start, end, and duration;
|
- execution start, end, and duration;
|
||||||
- validation status and bounded diagnostics; and
|
- validation status and bounded diagnostics; and
|
||||||
- the path of any separately persisted raw generated output.
|
- paths of separately persisted raw output, normalized generated text, render
|
||||||
|
context, managed Markdown, and other artifacts reached by the workflow.
|
||||||
|
|
||||||
Provider endpoints, full effective model parameter maps, rendered messages,
|
Provider endpoints, full effective model parameter maps, rendered messages,
|
||||||
schema bodies, data-package contents, and generated content do not belong in
|
schema bodies, data-package contents, and generated content do not belong in
|
||||||
routine metadata or CLI summaries.
|
routine metadata or CLI summaries.
|
||||||
|
|
||||||
Rendered messages and other content-rich preparation or response diagnostics
|
Rendered messages and other content-rich preparation or response diagnostics
|
||||||
will be available only through an explicitly enabled debug mechanism. Debug
|
are available only when the operator supplies
|
||||||
artifacts must be documented as potentially sensitive, must not contain
|
`--llm-debug-dir <path>` to a single-report or batch command.
|
||||||
credentials, and must have a clear operator-owned retention policy.
|
|
||||||
|
- There is no persistent YAML setting for debug capture.
|
||||||
|
- The debug root is validated or created before weather collection or provider
|
||||||
|
work. A requested destination that cannot be secured or written is an error.
|
||||||
|
- Artifacts are grouped beneath
|
||||||
|
`<path>/<report-id>/<valid-date>/<run-id>/`.
|
||||||
|
- Directories and files use owner-only permissions and atomic writes.
|
||||||
|
- Debug artifacts may contain rendered messages and content-rich preparation
|
||||||
|
or response diagnostics, but never credentials.
|
||||||
|
- The debug path appears in command output only when debug capture is enabled;
|
||||||
|
it is not added to routine durable metadata.
|
||||||
|
- Debug artifacts are not cache or comparison inputs. Their retention is owned
|
||||||
|
by the operator who selected the directory.
|
||||||
|
|
||||||
|
### Artifact Identities And Versions
|
||||||
|
|
||||||
|
Weatherreporter replaces Scriptorium-specific artifact identities rather than
|
||||||
|
reusing names whose meanings have changed:
|
||||||
|
|
||||||
|
- `PromptPreparationArtifact` uses schema version
|
||||||
|
`weatherreporter.prompt_preparation.v1`, is written as
|
||||||
|
`prompt_preparation.<runID>.json`, and is referenced by
|
||||||
|
`preparationPath`.
|
||||||
|
- `PromptExecutionArtifact` uses schema version
|
||||||
|
`weatherreporter.prompt_execution.v1`, is written as
|
||||||
|
`prompt_execution.<runID>.json`, and is referenced by `executionPath`.
|
||||||
|
- Run metadata advances to `weatherreporter.metadata.v2` and uses those new
|
||||||
|
path fields.
|
||||||
|
|
||||||
|
Preparation files remain beneath the existing configurable `preflight/`
|
||||||
|
directory, and execution files remain beneath the existing `snapshots/` tree.
|
||||||
|
The stable physical grouping limits deployment disruption without preserving
|
||||||
|
misleading Scriptorium-era filenames or field names. Raw generated output,
|
||||||
|
normalized generated text, render context, managed Markdown, and other
|
||||||
|
artifacts whose meanings have not changed retain their existing names and
|
||||||
|
locations.
|
||||||
|
|
||||||
|
Run inspection remains able to read `weatherreporter.metadata.v1` and its
|
||||||
|
legacy `preflightPath` and `generatedTextResultPath` references. New runs write
|
||||||
|
only the v2 metadata and new artifact names; Weatherreporter does not
|
||||||
|
dual-write deprecated aliases. CLI summary fields adopt `preparationPath` and
|
||||||
|
`executionPath` as an explicit, documented contract change.
|
||||||
|
|
||||||
## Failure Contract
|
## Failure Contract
|
||||||
|
|
||||||
Status: Accepted design constraints; unimplemented.
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
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
|
- A preparation failure produces a redacted weatherreporter-owned failure
|
||||||
receipt with report, RunID, prompt, stage, timing, and classified error
|
receipt with report, RunID, prompt, stage, timing, and classified error
|
||||||
context. It does not fabricate a Promptkit preparation result.
|
context. It does not fabricate Promptkit preparation details.
|
||||||
- An operational execution failure retains the successful preparation record
|
- An operational execution failure retains the successful preparation record
|
||||||
and adds a redacted execution failure receipt. No partial Promptkit result or
|
and adds a redacted execution failure receipt. No partial Promptkit result or
|
||||||
model output is invented.
|
model output is invented.
|
||||||
- A Promptkit validation failure retains the returned result, raw generated
|
- A Promptkit validation rejection retains the returned result, raw generated
|
||||||
output, validation details, and safe provenance before the report fails.
|
output, validation details, and safe provenance before the report fails.
|
||||||
- A later weatherreporter generated-text decode, domain-validation, or template
|
- A later generated-text decode, domain-validation, or template failure
|
||||||
failure retains every raw and validated artifact reached before that stage.
|
retains every raw and validated artifact reached before that stage.
|
||||||
- Context cancellation takes precedence when the caller context is canceled.
|
- Caller cancellation takes precedence when the active workflow context is
|
||||||
- Promptkit capacity rejection maps to a weatherreporter-owned error category.
|
canceled.
|
||||||
It is an operational report failure, not invalid model output.
|
- `promptkit.CapacityError` is recognized with `errors.As`; its backend ID is
|
||||||
|
copied into a weatherreporter-owned capacity error while
|
||||||
|
`ErrCapacityExceeded` remains the classification.
|
||||||
|
- Capacity rejection is an operational report failure, not invalid model
|
||||||
|
output, and does not trigger an automatic retry.
|
||||||
|
- Other Promptkit public error identities are translated into the narrow
|
||||||
|
weatherreporter error categories needed by CLI, metadata, and batch
|
||||||
|
behavior. Diagnostic prose is not parsed as a contract.
|
||||||
- Single-report commands return the classified failure with available
|
- Single-report commands return the classified failure with available
|
||||||
inspectable paths.
|
inspectable paths.
|
||||||
- Batch runs continue independent later reports under the existing batch
|
- Batch runs continue independent later reports under the existing batch
|
||||||
failure policy.
|
failure policy.
|
||||||
- The migration adds no automatic retries. Any future retry policy belongs to
|
- Any future retry policy belongs to app orchestration, not the adapter.
|
||||||
app orchestration, not the Promptkit adapter.
|
|
||||||
|
|
||||||
## Compatibility Requirements
|
## Compatibility Requirements
|
||||||
|
|
||||||
Status: Accepted design constraints; unimplemented.
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
- Report IDs, prompt IDs, report selection, valid periods, artifact grouping,
|
- Daily, Today, Tomorrow, and Hourly report IDs, prompt IDs, valid periods,
|
||||||
output names, and distributor bundle behavior remain stable.
|
artifact grouping, output names, and Distributor bundle behavior remain
|
||||||
|
stable.
|
||||||
|
- Morning and evening batch collection, planning, ordering, and continuation
|
||||||
|
behavior remains stable.
|
||||||
- Module snapshot and Recent Changes behavior remains deterministic.
|
- Module snapshot and Recent Changes behavior remains deterministic.
|
||||||
- Promptkit receives only the existing curated prompt-input boundary.
|
- Promptkit receives only the existing curated prompt-input boundary.
|
||||||
- Generated reports continue to use the managed Markdown path as the
|
- Managed Markdown remains the Distributor upload source.
|
||||||
distributor upload source.
|
|
||||||
- RunID lookup and inspection remain available for successful and failed runs.
|
- RunID lookup and inspection remain available for successful and failed runs.
|
||||||
- Existing managed state paths remain stable where their meaning is unchanged.
|
- Existing managed paths remain stable where their meaning is unchanged.
|
||||||
Scriptorium-specific artifact names or schemas may change when retaining
|
Scriptorium-specific artifact names or schemas change when retaining them
|
||||||
them would misrepresent the new contract.
|
would misrepresent the Promptkit contract.
|
||||||
- Any artifact or metadata schema change is explicit, documented, and covered
|
- Existing v1 run metadata and referenced artifacts remain inspectable after
|
||||||
by state and inspection tests.
|
the migration. New runs use the v2 metadata and Promptkit-era artifact
|
||||||
|
identities without dual-writing deprecated aliases.
|
||||||
|
- Artifact or metadata schema changes are explicit, documented, and covered by
|
||||||
|
state and inspection tests.
|
||||||
- Prompt or generated content is not added to routine logs or CLI summaries.
|
- Prompt or generated content is not added to routine logs or CLI summaries.
|
||||||
- Tests do not require live Promptkit providers or credentials.
|
- Tests do not require live providers or credentials.
|
||||||
|
- Removing incomplete three-day, weekend, and storm surfaces is documented as
|
||||||
|
correction of an unfinished product boundary, not as successful Promptkit
|
||||||
|
migration of those reports.
|
||||||
|
|
||||||
## Verification And Completion Criteria
|
## Verification And Completion Criteria
|
||||||
|
|
||||||
Status: Proposed completion criteria for the unimplemented migration.
|
Status: Completed and verified.
|
||||||
|
|
||||||
The migration is complete when:
|
Completion was verified by the following outcomes:
|
||||||
|
|
||||||
- all seven reports prepare and execute through Promptkit using embedded
|
- the four operational reports inspect, prepare, and execute through Promptkit
|
||||||
report-owned assets;
|
`v0.4.0` using embedded report-owned assets;
|
||||||
- direct-Markdown and generated-text-template paths have deterministic offline
|
- every report uses exact prompt version `1.0.0`, requires the YAML
|
||||||
adapter and app-level coverage;
|
`data_package`, and declares the expected JSON Schema output contract;
|
||||||
- preparation, provider failure, capacity rejection, cancellation, timeout,
|
- prepared execution persists a safe preparation record before provider work
|
||||||
Promptkit validation failure, generated-text validation failure, template
|
and executes the same frozen snapshot;
|
||||||
failure, and successful generation preserve their specified artifacts;
|
- deterministic offline adapter and app tests cover success, preparation
|
||||||
- morning and evening batches construct one shared engine and preserve current
|
failure, credential revalidation, capacity rejection, cancellation, timeout,
|
||||||
|
generation failure, Promptkit validation rejection, generated-text domain
|
||||||
|
failure, template failure, and handle discard;
|
||||||
|
- morning and evening batches construct one engine and preserve current
|
||||||
collection, planning, ordering, continuation, output, and notification
|
collection, planning, ordering, continuation, output, and notification
|
||||||
behavior;
|
behavior;
|
||||||
|
- the temporary corpus has been reconciled into one runtime prompt/schema
|
||||||
|
source without duplicate provider-facing schemas;
|
||||||
- configuration examples load and contain no Scriptorium fields;
|
- configuration examples load and contain no Scriptorium fields;
|
||||||
- CLI summaries and inspection commands expose the new artifact contract
|
- CLI summaries and inspection commands expose the new project-owned artifact
|
||||||
without Promptkit dependency types;
|
contract without Promptkit types;
|
||||||
- Scriptorium code, configuration, tests, and runtime documentation have been
|
- Scriptorium code, configuration, tests, and runtime documentation have been
|
||||||
removed;
|
removed;
|
||||||
|
- incomplete three-day, weekend, and storm commands, registry entries, tests,
|
||||||
|
and current-behavior documentation have been removed or moved to roadmap
|
||||||
|
scope;
|
||||||
- non-roadmap documentation describes only the implemented Promptkit
|
- non-roadmap documentation describes only the implemented Promptkit
|
||||||
integration;
|
integration;
|
||||||
- `go test ./...`, CLI help validation, and `git diff --check` pass; and
|
- `go test ./...`, required focused or race-enabled checks, CLI help
|
||||||
- no committed `go.work`, local `replace`, live-provider test, or secret-bearing
|
validation, and `git diff --check` pass; and
|
||||||
fixture remains.
|
- no committed `go.work`, local `replace`, live-provider test, or
|
||||||
|
secret-bearing fixture remains.
|
||||||
|
|
||||||
Fixture-based comparison with the current Scriptorium behavior is sufficient
|
Fixture-based comparison with prior Scriptorium behavior is sufficient.
|
||||||
for migration verification. A production-selectable dual-run period is not
|
Production dual-run is not required because model calls are nondeterministic,
|
||||||
required because model calls are nondeterministic, costly, and difficult to
|
costly, and difficult to compare meaningfully.
|
||||||
compare meaningfully.
|
|
||||||
|
|
||||||
## External Prerequisite
|
## Decision Status
|
||||||
|
|
||||||
Status: Required and unimplemented.
|
Status: Completed.
|
||||||
|
|
||||||
Before implementing the embedded asset stage, the current Scriptorium prompt
|
The roadmap has no remaining open product or architecture questions. Later
|
||||||
corpus must be made available in this repository. It should include the seven
|
changes to this completed scope require new roadmap or decision-record scope
|
||||||
prompt definitions, referenced content files, private response schemas,
|
rather than implicit changes to this historical record.
|
||||||
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?
|
|
||||||
|
|||||||
@@ -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,7 +121,7 @@ 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 |
|
||||||
|
|||||||
@@ -1,271 +1,48 @@
|
|||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
Use the error from the command together with the run artifacts when a run ID is
|
Keep failed workspace artifacts in place. When a RunID is available, start
|
||||||
available. Start with [`inspect metadata`](cli.md#inspection-commands) to identify the
|
with `weatherreporter inspect metadata RUN_ID` and use the paths in its result.
|
||||||
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
|
## Prompt inspection or credentials fail before collection
|
||||||
|
|
||||||
Symptom: The command exits before it creates a run, with an unknown-flag,
|
A prompt/version, contract, selected profile, unsupported direct-key profile,
|
||||||
missing-argument, invalid date or time bound, invalid timezone, or
|
or required environment credential can fail before weather collection. Correct
|
||||||
`weather_api.base_url` message.
|
the configured `promptkit` profile or profile source, confirm the exact
|
||||||
|
Promptkit asset is available, and supply any reported environment credential.
|
||||||
|
Do not add provider keys to YAML. See [configuration](config.md).
|
||||||
|
|
||||||
Likely cause: The command does not accept that option for the requested report,
|
## Preparation, capacity, or execution fails
|
||||||
or required command and configuration values are absent or malformed.
|
|
||||||
|
|
||||||
Diagnostic: Compare the command with [`generate` and `run`](cli.md#commands-and-usage)
|
A preparation failure occurs before provider work; an execution failure occurs
|
||||||
and review the configured value named in the error. `generate daily` requires
|
after preparation. Both leave safe provenance and metadata when reached. A
|
||||||
`--date`; `generate storm` requires both `--start` and `--end`.
|
capacity error for one batch report does not retry that report or prevent later
|
||||||
|
independent reports. Inspect the preparation or execution path, correct the
|
||||||
Safe fix: Correct only the reported option or configuration value. Use an
|
profile/backend condition, and create a new run. See [operations](operations.md).
|
||||||
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
|
## Generated text fails validation
|
||||||
|
|
||||||
Symptom: A generated-text report fails after Scriptorium returns text, with a
|
Raw generated output may be saved but Markdown is not rendered when the JSON
|
||||||
message about generated text or required report content.
|
does not match the report schema. Correct the Promptkit prompt/profile behavior
|
||||||
|
or the matching schema and validator in source control; do not edit raw output
|
||||||
|
to treat it as validated. See [templates](templates.md).
|
||||||
|
|
||||||
Likely cause: Returned text does not meet the report's validation rules.
|
## Debug capture fails
|
||||||
|
|
||||||
Diagnostic: Use `weatherreporter inspect metadata RUN_ID` to find the saved raw
|
`--llm-debug-dir` must be an absolute secure directory outside workspace state.
|
||||||
generated-text artifact, and inspect it alongside the reported validation
|
A debug-write failure stops the affected report to avoid continuing without the
|
||||||
message.
|
requested diagnostic. Repair the named path's ownership or permissions, then
|
||||||
|
rerun. Treat capture files as sensitive. See [operations](operations.md).
|
||||||
|
|
||||||
Safe fix: Correct the upstream prompt or generation configuration that caused
|
## Weather, state, output, or notification fails
|
||||||
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).
|
Collection errors precede planning. Later filesystem, output-copy, template,
|
||||||
|
or Distributor errors retain the reached safe paths in the summary. Repair only
|
||||||
## Report template rendering fails
|
the reported endpoint or path, leave successful managed reports intact, and
|
||||||
|
rerun the affected report or batch. A batch notification is intentionally
|
||||||
Symptom: Scriptorium output is available, but the report fails while building
|
skipped when any report item fails.
|
||||||
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
|
## Secrets cannot be loaded
|
||||||
|
|
||||||
Symptom: Startup reports `read secrets directory`, `secret file`, or a token
|
Secret files must be regular non-symlink files directly beneath
|
||||||
environment-variable error before the affected service can be used.
|
`secrets.directory` with valid environment-variable basenames. Correct the
|
||||||
|
reported file or directory without placing secret values in YAML.
|
||||||
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).
|
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ missing_source:
|
|||||||
sources:
|
sources:
|
||||||
alerts: none
|
alerts: none
|
||||||
|
|
||||||
scriptorium:
|
promptkit:
|
||||||
binary: scriptorium
|
|
||||||
timeout: 2m
|
timeout: 2m
|
||||||
|
local:
|
||||||
|
concurrency_limit: 1
|
||||||
|
|
||||||
workspace:
|
workspace:
|
||||||
root: workspace
|
root: workspace
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -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.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -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.4.0 h1:WHRQEt3BVBAR7hQePBaGtNXpzrs59mlr/42nQzwgOz4=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit v0.4.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
github.com/aws/aws-sdk-go-v2 v1.41.9 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=
|
||||||
|
|||||||
322
internal/adapters/promptkit/adapter.go
Normal file
322
internal/adapters/promptkit/adapter.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// Package promptkitadapter implements promptexec with Promptkit.
|
||||||
|
package promptkitadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config selects the Promptkit sources and optional local backend for one engine.
|
||||||
|
type Config struct {
|
||||||
|
ProfileDirectory string
|
||||||
|
ProfileFile string
|
||||||
|
LocalEndpoint string
|
||||||
|
LocalConcurrencyLimit int
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||||
|
type Adapter struct {
|
||||||
|
engine *promptkit.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ promptexec.Executor = (*Adapter)(nil)
|
||||||
|
|
||||||
|
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
|
||||||
|
func New(config Config) (*Adapter, error) {
|
||||||
|
return newAdapter(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
|
||||||
|
if config.ProfileDirectory != "" && config.ProfileFile != "" {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
|
||||||
|
}
|
||||||
|
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []promptkit.Option{
|
||||||
|
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||||
|
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||||
|
}
|
||||||
|
if config.ProfileFile != "" {
|
||||||
|
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
||||||
|
}
|
||||||
|
if config.LocalEndpoint != "" {
|
||||||
|
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
|
||||||
|
}
|
||||||
|
options = append(options, additionalOptions...)
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
ProfileDir: config.ProfileDirectory,
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
}, options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyConfigurationError(err)
|
||||||
|
}
|
||||||
|
return &Adapter{engine: engine}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
|
||||||
|
return newAdapter(config, promptkit.WithLLMClient(client))
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
|
||||||
|
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
|
||||||
|
if adapter == nil || adapter.engine == nil {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||||
|
}
|
||||||
|
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
|
||||||
|
if err != nil {
|
||||||
|
return promptexec.PromptInspection{}, classifyError(err)
|
||||||
|
}
|
||||||
|
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
|
||||||
|
for index, input := range inspection.Inputs {
|
||||||
|
inputs[index] = promptexec.InputDefinition{
|
||||||
|
Name: input.Name,
|
||||||
|
Required: input.Required,
|
||||||
|
ContentType: input.ContentType,
|
||||||
|
Description: input.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return promptexec.PromptInspection{
|
||||||
|
PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion,
|
||||||
|
PromptHash: inspection.PromptHash,
|
||||||
|
DefaultProfileID: inspection.DefaultProfileID,
|
||||||
|
Inputs: inputs,
|
||||||
|
Output: outputContract(inspection.OutputContract),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
|
||||||
|
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
|
||||||
|
if adapter == nil || adapter.engine == nil {
|
||||||
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||||
|
}
|
||||||
|
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return promptexec.ProfileInspection{}, classifyError(err)
|
||||||
|
}
|
||||||
|
return promptexec.ProfileInspection{
|
||||||
|
ProfileID: inspection.ProfileID,
|
||||||
|
BackendID: inspection.EffectiveModelParams.BackendID,
|
||||||
|
ModelName: inspection.EffectiveModelParams.Model,
|
||||||
|
CredentialRequired: inspection.APIKeyRequired,
|
||||||
|
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute prepares one exact inline data package, invokes prepared after a
|
||||||
|
// successful preparation, and then runs the same opaque prepared handle.
|
||||||
|
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
if adapter == nil || adapter.engine == nil {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||||
|
}
|
||||||
|
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: request.PromptID,
|
||||||
|
PromptVersion: request.PromptVersion,
|
||||||
|
ProfileID: request.ProfileID,
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"data_package": promptkit.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyError(err)
|
||||||
|
}
|
||||||
|
defer prepared.Discard()
|
||||||
|
|
||||||
|
details := prepared.Details()
|
||||||
|
preparation, debug := preparationValues(details, request.DataPackagePath, request.CaptureDebug)
|
||||||
|
if preparedCallback != nil {
|
||||||
|
if err := preparedCallback(preparation, debug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := adapter.engine.RunPrepared(ctx, prepared)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyError(err)
|
||||||
|
}
|
||||||
|
return executionValue(result, request.DataPackagePath, request.CaptureDebug), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
||||||
|
return promptexec.OutputContract{
|
||||||
|
Format: string(value.Format),
|
||||||
|
ValidationMode: string(value.ValidationMode),
|
||||||
|
SchemaPath: value.SchemaPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparationValues(value promptkit.PreparedRun, dataPackagePath string, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
||||||
|
preparation := promptexec.Preparation{
|
||||||
|
PromptID: value.PromptID,
|
||||||
|
PromptVersion: value.PromptVersion,
|
||||||
|
PromptHash: value.PromptHash,
|
||||||
|
RenderedPromptHash: value.RenderedPromptHash,
|
||||||
|
InputHashes: copyInputHashes(value.InputHashes),
|
||||||
|
ProfileID: value.SelectedProfileID,
|
||||||
|
BackendID: value.SelectedBackendID,
|
||||||
|
ModelName: value.EffectiveModelParams.Model,
|
||||||
|
Output: outputContract(value.OutputContract),
|
||||||
|
StartedAt: value.StartTime,
|
||||||
|
EndedAt: value.EndTime,
|
||||||
|
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
}
|
||||||
|
if !captureDebug {
|
||||||
|
return preparation, nil
|
||||||
|
}
|
||||||
|
debug := &promptexec.PreparationDebug{
|
||||||
|
RenderedMessages: renderedMessages(value.Messages),
|
||||||
|
Endpoint: value.EffectiveModelParams.Endpoint,
|
||||||
|
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
|
||||||
|
}
|
||||||
|
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
|
||||||
|
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
|
||||||
|
}
|
||||||
|
return preparation, debug
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionValue(value *promptkit.RunResult, dataPackagePath string, captureDebug bool) *promptexec.Execution {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
validation := promptexec.NewValidation(
|
||||||
|
promptexec.ValidationStatus(value.Validation.Status),
|
||||||
|
string(value.Validation.Mode),
|
||||||
|
value.Validation.SchemaPath,
|
||||||
|
value.Validation.Errors,
|
||||||
|
)
|
||||||
|
execution := &promptexec.Execution{
|
||||||
|
RunID: value.RunID,
|
||||||
|
PromptID: value.PromptID,
|
||||||
|
PromptVersion: value.PromptVersion,
|
||||||
|
PromptHash: value.PromptHash,
|
||||||
|
RenderedPromptHash: value.RenderedPromptHash,
|
||||||
|
InputHashes: copyInputHashes(value.InputHashes),
|
||||||
|
ProfileID: value.SelectedProfileID,
|
||||||
|
BackendID: value.SelectedBackendID,
|
||||||
|
ModelName: value.ModelName,
|
||||||
|
GeneratedHash: value.Artifact.Hash,
|
||||||
|
Usage: promptexec.TokenUsage{
|
||||||
|
PromptTokens: value.Usage.PromptTokens,
|
||||||
|
CompletionTokens: value.Usage.CompletionTokens,
|
||||||
|
TotalTokens: value.Usage.TotalTokens,
|
||||||
|
CachedTokens: value.Usage.CachedTokens,
|
||||||
|
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
||||||
|
},
|
||||||
|
StartedAt: value.StartTime,
|
||||||
|
EndedAt: value.EndTime,
|
||||||
|
Duration: value.Duration,
|
||||||
|
Validation: validation,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
RawOutput: []byte(value.RawOutput),
|
||||||
|
}
|
||||||
|
if captureDebug {
|
||||||
|
execution.Debug = &promptexec.ExecutionDebug{
|
||||||
|
RawOutput: append([]byte(nil), value.RawOutput...),
|
||||||
|
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return execution
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
|
||||||
|
messages := make([]promptexec.RenderedMessage, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyInputHashes(values map[string]string) map[string]string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := make(map[string]string, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
copy[key] = value
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||||
|
parameters := struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
TopP float64 `json:"top_p"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
ServiceTier string `json:"service_tier"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
|
ExtraParams map[string]any `json:"extra_params"`
|
||||||
|
}{
|
||||||
|
Temperature: value.Temperature,
|
||||||
|
MaxTokens: value.MaxTokens,
|
||||||
|
TopP: value.TopP,
|
||||||
|
TimeoutSeconds: value.TimeoutSeconds,
|
||||||
|
ServiceTier: value.ServiceTier,
|
||||||
|
ReasoningEffort: value.ReasoningEffort,
|
||||||
|
ExtraParams: value.ExtraParams,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(parameters)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyConfigurationError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
|
||||||
|
}
|
||||||
|
var capacityError *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityError) {
|
||||||
|
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, promptkit.ErrInvalidConfig):
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptNotFound):
|
||||||
|
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptLoad):
|
||||||
|
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrProfileNotFound):
|
||||||
|
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
|
||||||
|
case errors.Is(err, promptkit.ErrProfileLoad):
|
||||||
|
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
|
||||||
|
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
|
||||||
|
case errors.Is(err, promptkit.ErrArtifactLoad):
|
||||||
|
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptRender):
|
||||||
|
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
|
||||||
|
case errors.Is(err, promptkit.ErrCapacityExceeded):
|
||||||
|
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
|
||||||
|
case errors.Is(err, promptkit.ErrLLMGenerate):
|
||||||
|
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
|
||||||
|
case errors.Is(err, promptkit.ErrValidation):
|
||||||
|
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
|
||||||
|
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
|
||||||
|
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
|
||||||
|
default:
|
||||||
|
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
384
internal/adapters/promptkit/adapter_test.go
Normal file
384
internal/adapters/promptkit/adapter_test.go
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
package promptkitadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
response *promptkit.GenerateResponse
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
requests []promptkit.GenerateRequest
|
||||||
|
block bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingReader struct {
|
||||||
|
ref promptkit.ArtifactRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||||
|
reader.ref = ref
|
||||||
|
return &promptkit.Artifact{
|
||||||
|
Name: "data_package",
|
||||||
|
ContentType: "application/yaml",
|
||||||
|
Body: []byte(ref.Body),
|
||||||
|
URI: ref.URI,
|
||||||
|
Hash: "input-hash",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
client.mu.Lock()
|
||||||
|
client.calls++
|
||||||
|
client.requests = append(client.requests, request)
|
||||||
|
block := client.block
|
||||||
|
response := client.response
|
||||||
|
err := client.err
|
||||||
|
client.mu.Unlock()
|
||||||
|
if block {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) callCount() int {
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
return client.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) request() promptkit.GenerateRequest {
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
return client.requests[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptAndProfile(t *testing.T) {
|
||||||
|
adapter := newTestAdapter(t, &fakeClient{})
|
||||||
|
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPrompt() error = %v", err)
|
||||||
|
}
|
||||||
|
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
|
||||||
|
t.Fatalf("inspection = %#v", inspection)
|
||||||
|
}
|
||||||
|
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
|
||||||
|
t.Fatalf("inputs = %#v", inspection.Inputs)
|
||||||
|
}
|
||||||
|
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
|
||||||
|
t.Fatalf("output = %#v", inspection.Output)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
|
||||||
|
t.Fatalf("profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
|
||||||
|
t.Fatalf("profile leaks endpoint: %#v", profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectProfile(builtin) error = %v", err)
|
||||||
|
}
|
||||||
|
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
|
||||||
|
t.Fatalf("builtin profile = %#v", builtin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
request := testExecuteRequest()
|
||||||
|
callbackCalls := 0
|
||||||
|
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
callbackCalls++
|
||||||
|
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.DataPackagePath != request.DataPackagePath || preparation.ModelName != "test-model" {
|
||||||
|
t.Fatalf("preparation = %#v", preparation)
|
||||||
|
}
|
||||||
|
if debug != nil {
|
||||||
|
t.Fatalf("debug = %#v, want nil", debug)
|
||||||
|
}
|
||||||
|
if client.callCount() != 0 {
|
||||||
|
t.Fatal("provider called before preparation callback")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if callbackCalls != 1 || client.callCount() != 1 {
|
||||||
|
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
|
||||||
|
}
|
||||||
|
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content || result.DataPackagePath != request.DataPackagePath {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
if result.Debug != nil {
|
||||||
|
t.Fatalf("debug = %#v, want nil", result.Debug)
|
||||||
|
}
|
||||||
|
providerRequest := client.request()
|
||||||
|
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
|
||||||
|
t.Fatalf("provider target = %#v", providerRequest.Target)
|
||||||
|
}
|
||||||
|
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
|
||||||
|
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
reader := &recordingReader{}
|
||||||
|
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
|
||||||
|
request := testExecuteRequest()
|
||||||
|
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != request.DataPackagePath || reader.ref.Body != string(request.DataPackage) {
|
||||||
|
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
request := testExecuteRequest()
|
||||||
|
request.CaptureDebug = true
|
||||||
|
var preparationDebug *promptexec.PreparationDebug
|
||||||
|
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
preparationDebug = debug
|
||||||
|
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
|
||||||
|
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
|
||||||
|
t.Fatalf("preparation debug = %#v", preparationDebug)
|
||||||
|
}
|
||||||
|
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
|
||||||
|
t.Fatalf("execution debug = %#v", result.Debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
callbackError := errors.New("save preparation")
|
||||||
|
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
|
||||||
|
return callbackError
|
||||||
|
})
|
||||||
|
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
|
||||||
|
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
||||||
|
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
client *fakeClient
|
||||||
|
context func() (context.Context, context.CancelFunc)
|
||||||
|
category promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "generation",
|
||||||
|
client: &fakeClient{err: errors.New("provider response body")},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithCancel(context.Background())
|
||||||
|
},
|
||||||
|
category: promptexec.Generation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canceled",
|
||||||
|
client: &fakeClient{block: true},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx, func() {}
|
||||||
|
},
|
||||||
|
category: promptexec.Canceled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deadline",
|
||||||
|
client: &fakeClient{block: true},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), time.Nanosecond)
|
||||||
|
},
|
||||||
|
category: promptexec.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
adapter := newTestAdapter(t, test.client)
|
||||||
|
ctx, cancel := test.context()
|
||||||
|
defer cancel()
|
||||||
|
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||||
|
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "provider response body") {
|
||||||
|
t.Fatalf("error leaks provider detail: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyPromptkitErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
category promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
|
||||||
|
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
|
||||||
|
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
|
||||||
|
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
|
||||||
|
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
|
||||||
|
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
|
||||||
|
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
|
||||||
|
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
|
||||||
|
{promptkit.ErrPromptRender, promptexec.PromptRender},
|
||||||
|
{promptkit.ErrLLMGenerate, promptexec.Generation},
|
||||||
|
{promptkit.ErrValidation, promptexec.OperationalValidation},
|
||||||
|
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(string(test.category), func(t *testing.T) {
|
||||||
|
got := classifyError(test.err)
|
||||||
|
if promptexec.CategoryOf(got) != test.category {
|
||||||
|
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesConfiguration(t *testing.T) {
|
||||||
|
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("profile source error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("local concurrency error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("local endpoint error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||||
|
profiles := testProfileDirectory(t, `id: local-profile
|
||||||
|
backend: local
|
||||||
|
model: local-model
|
||||||
|
`)
|
||||||
|
adapter, err := newAdapterForTest(Config{
|
||||||
|
ProfileDirectory: profiles,
|
||||||
|
LocalEndpoint: "https://local.example/v1",
|
||||||
|
LocalConcurrencyLimit: 1,
|
||||||
|
}, &fakeClient{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapterForTest(local) error = %v", err)
|
||||||
|
}
|
||||||
|
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
|
||||||
|
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
|
||||||
|
t.Fatalf("local profile/error = %#v/%v", profile, err)
|
||||||
|
}
|
||||||
|
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
|
||||||
|
t.Fatalf("capacity classification = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialProfiles := testProfileDirectory(t, `id: credential-profile
|
||||||
|
endpoint: https://profile.example/v1
|
||||||
|
model: test-model
|
||||||
|
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||||
|
`)
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapterForTest(credential) error = %v", err)
|
||||||
|
}
|
||||||
|
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
|
||||||
|
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
|
||||||
|
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
|
||||||
|
}
|
||||||
|
request := testExecuteRequest()
|
||||||
|
request.ProfileID = "credential-profile"
|
||||||
|
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||||
|
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
|
||||||
|
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
|
||||||
|
return newTestAdapterWithOptions(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
||||||
|
t.Helper()
|
||||||
|
profiles := testProfileDirectory(t, `id: test-profile
|
||||||
|
endpoint: https://profile.example/v1
|
||||||
|
model: test-model
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 300
|
||||||
|
top_p: 1
|
||||||
|
timeout_seconds: 30
|
||||||
|
`)
|
||||||
|
options = append(options, promptkit.WithLLMClient(client))
|
||||||
|
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapter() error = %v", err)
|
||||||
|
}
|
||||||
|
return adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProfileDirectory(t *testing.T, profile string) string {
|
||||||
|
t.Helper()
|
||||||
|
profiles := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExecuteRequest() promptexec.ExecuteRequest {
|
||||||
|
return promptexec.ExecuteRequest{
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
PromptVersion: "1.0.0",
|
||||||
|
ProfileID: "test-profile",
|
||||||
|
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||||
|
DataPackagePath: "data-packages/daily/data_package.yaml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validResponse() *promptkit.GenerateResponse {
|
||||||
|
return &promptkit.GenerateResponse{
|
||||||
|
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`,
|
||||||
|
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,11 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"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/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
@@ -17,8 +15,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"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/state"
|
||||||
@@ -33,9 +31,6 @@ const (
|
|||||||
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
||||||
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
||||||
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
||||||
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
|
|
||||||
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
|
|
||||||
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type BatchKind string
|
type BatchKind string
|
||||||
@@ -46,26 +41,28 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Report ReportKind
|
Report ReportKind
|
||||||
OutputPath string
|
OutputPath string
|
||||||
Now time.Time
|
LLMDebugDir string
|
||||||
Date time.Time
|
Now time.Time
|
||||||
StormStart time.Time
|
Date time.Time
|
||||||
StormEnd time.Time
|
Collector Collector
|
||||||
Collector Collector
|
Notifier Notifier
|
||||||
Notifier Notifier
|
Executor promptexec.Executor
|
||||||
|
Store state.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchRequest struct {
|
type BatchRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Batch BatchKind
|
Batch BatchKind
|
||||||
Now time.Time
|
Now time.Time
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Collector Collector
|
LLMDebugDir string
|
||||||
Renderer Renderer
|
Collector Collector
|
||||||
Store state.Store
|
Executor promptexec.Executor
|
||||||
Notifier Notifier
|
Store state.Store
|
||||||
|
Notifier Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchBundleRequest struct {
|
type FetchBundleRequest struct {
|
||||||
@@ -83,38 +80,25 @@ type ReportFacts struct {
|
|||||||
Derived facts.DerivedFacts
|
Derived facts.DerivedFacts
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
Resolved report.Resolved
|
|
||||||
OutputPath string
|
|
||||||
Collection collect.Result
|
|
||||||
Renderer Renderer
|
|
||||||
Store state.Store
|
|
||||||
Notifier Notifier
|
|
||||||
noNotify bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
ModuleSnapshot module.Snapshot
|
ModuleSnapshot module.Snapshot
|
||||||
ModuleSnapshotPath string
|
ModuleSnapshotPath string
|
||||||
DataPackage promptinput.Package
|
DataPackage promptinput.Package
|
||||||
DataPackagePath string
|
DataPackagePath string
|
||||||
PreflightPath string
|
PreparationPath string
|
||||||
ReportPath string
|
ExecutionPath string
|
||||||
OutputPath string
|
LLMDebugPath string
|
||||||
NotificationPath string
|
ReportPath string
|
||||||
Metadata state.Metadata
|
OutputPath string
|
||||||
MetadataPath string
|
NotificationPath string
|
||||||
PriorSnapshot *state.PriorSnapshot
|
Metadata state.Metadata
|
||||||
RecentChanges []changes.Change
|
MetadataPath string
|
||||||
RenderResult *scriptorium.RenderResult
|
PriorSnapshot *state.PriorSnapshot
|
||||||
RunResult *scriptorium.RunResult
|
RecentChanges []changes.Change
|
||||||
StructuredRunResult *scriptorium.StructuredRunResult
|
GeneratedTextRawPath string
|
||||||
GeneratedTextRawPath string
|
GeneratedTextPath string
|
||||||
GeneratedTextResultPath string
|
RenderContextPath string
|
||||||
GeneratedTextPath string
|
Notification *NotificationResult
|
||||||
RenderContextPath string
|
|
||||||
Notification *NotificationResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
@@ -162,7 +146,9 @@ type BatchReportResult struct {
|
|||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
PreflightPath string `json:"preflightPath,omitempty"`
|
PreparationPath string `json:"preparationPath,omitempty"`
|
||||||
|
ExecutionPath string `json:"executionPath,omitempty"`
|
||||||
|
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||||
ReportPath string `json:"reportPath,omitempty"`
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
OutputPath string `json:"outputPath,omitempty"`
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
MetadataPath string `json:"metadataPath,omitempty"`
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
@@ -202,12 +188,6 @@ func batchReportFailures(result *BatchResult) int {
|
|||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
type Renderer interface {
|
|
||||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
|
||||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
|
||||||
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Collector interface {
|
type Collector interface {
|
||||||
Run(context.Context, collect.Request) (*collect.Result, error)
|
Run(context.Context, collect.Request) (*collect.Result, error)
|
||||||
}
|
}
|
||||||
@@ -277,24 +257,33 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
if now.IsZero() {
|
if now.IsZero() {
|
||||||
now = time.Now()
|
now = time.Now()
|
||||||
}
|
}
|
||||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolved, err := ResolveGenerate(req, now)
|
resolved, err := ResolveGenerate(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resolved.Definition.Generated {
|
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
return GenerateReport(ctx, ReportRequest{
|
if err != nil {
|
||||||
Config: req.Config,
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
Resolved: resolved,
|
|
||||||
OutputPath: req.OutputPath,
|
|
||||||
Collection: *collection,
|
|
||||||
Notifier: req.Notifier,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("generate is not implemented")
|
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Executor: req.Executor,
|
||||||
|
Promptkit: req.Config.Promptkit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return generatePromptReport(ctx, promptReportRequest{
|
||||||
|
GenerateRequest: req,
|
||||||
|
Resolved: resolved,
|
||||||
|
Collection: *collection,
|
||||||
|
Inspection: inspection,
|
||||||
|
DebugWriter: debugWriter,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunBatch(ctx context.Context, req BatchRequest) error {
|
func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||||
@@ -316,6 +305,22 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
|
}
|
||||||
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inspections, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
|
||||||
|
Resolved: candidates,
|
||||||
|
Executor: req.Executor,
|
||||||
|
Promptkit: req.Config.Promptkit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -335,58 +340,33 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
}
|
}
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, planned := range plannedReports {
|
|
||||||
resolved := planned.Resolved
|
|
||||||
if !resolved.Definition.Generated {
|
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, planned := range plannedReports {
|
for _, planned := range plannedReports {
|
||||||
resolved := planned.Resolved
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(planned)
|
item := batchReportResult(planned)
|
||||||
if paths, err := store.Paths(resolved); err == nil {
|
|
||||||
item.DataPackagePath = paths.DataPackage
|
|
||||||
item.PreflightPath = paths.Preflight
|
|
||||||
item.ReportPath = paths.RenderedReport
|
|
||||||
item.MetadataPath = paths.Metadata
|
|
||||||
}
|
|
||||||
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
||||||
reportResult, err := GenerateReport(ctx, ReportRequest{
|
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||||
Config: req.Config,
|
GenerateRequest: GenerateRequest{
|
||||||
Resolved: resolved,
|
Config: req.Config,
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
Collection: *collection,
|
Notifier: req.Notifier,
|
||||||
Renderer: req.Renderer,
|
Executor: req.Executor,
|
||||||
Store: store,
|
Store: store,
|
||||||
Notifier: req.Notifier,
|
},
|
||||||
noNotify: true,
|
Resolved: resolved,
|
||||||
|
Collection: *collection,
|
||||||
|
Inspection: inspections[resolved.Definition.ID],
|
||||||
|
DebugWriter: debugWriter,
|
||||||
|
noNotify: true,
|
||||||
})
|
})
|
||||||
|
if reportResult != nil {
|
||||||
|
copyBatchReportPaths(&item, reportResult)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
item.Status = "failed"
|
item.Status = "failed"
|
||||||
item.Error = err.Error()
|
item.Error = err.Error()
|
||||||
var notificationErr *NotificationError
|
|
||||||
if errors.As(err, ¬ificationErr) {
|
|
||||||
item.NotificationStatus = "failed"
|
|
||||||
item.NotificationError = notificationErr.Error()
|
|
||||||
item.NotificationPipelineID = notificationErr.Request.PipelineID
|
|
||||||
if paths, pathErr := store.Paths(resolved); pathErr == nil {
|
|
||||||
item.NotificationPath = paths.Notification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.Failed++
|
result.Failed++
|
||||||
} else {
|
} else {
|
||||||
item.Status = "succeeded"
|
item.Status = "succeeded"
|
||||||
item.DataPackagePath = reportResult.DataPackagePath
|
|
||||||
item.PreflightPath = reportResult.PreflightPath
|
|
||||||
item.ReportPath = reportResult.ReportPath
|
|
||||||
item.OutputPath = reportResult.OutputPath
|
|
||||||
item.MetadataPath = reportResult.MetadataPath
|
|
||||||
item.NotificationPath = reportResult.NotificationPath
|
|
||||||
if reportResult.Notification != nil {
|
|
||||||
item.NotificationStatus = reportResult.Notification.Status
|
|
||||||
item.NotificationRunID = reportResult.Notification.RunID
|
|
||||||
item.NotificationPipelineID = reportResult.Notification.PipelineID
|
|
||||||
}
|
|
||||||
result.Succeeded++
|
result.Succeeded++
|
||||||
}
|
}
|
||||||
result.Reports = append(result.Reports, item)
|
result.Reports = append(result.Reports, item)
|
||||||
@@ -405,6 +385,51 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
|
||||||
|
item.DataPackagePath = result.DataPackagePath
|
||||||
|
item.PreparationPath = result.PreparationPath
|
||||||
|
item.ExecutionPath = result.ExecutionPath
|
||||||
|
item.LLMDebugPath = result.LLMDebugPath
|
||||||
|
item.ReportPath = result.ReportPath
|
||||||
|
item.OutputPath = result.OutputPath
|
||||||
|
item.MetadataPath = result.MetadataPath
|
||||||
|
item.NotificationPath = result.NotificationPath
|
||||||
|
if result.Notification != nil {
|
||||||
|
item.NotificationStatus = result.Notification.Status
|
||||||
|
item.NotificationRunID = result.Notification.RunID
|
||||||
|
item.NotificationPipelineID = result.Notification.PipelineID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||||
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registry, err := reportRegistry(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := []report.ID{report.Tomorrow, report.Daily}
|
||||||
|
if req.Batch == BatchMorning {
|
||||||
|
ids = []report.ID{report.Today, report.Tomorrow, report.Daily}
|
||||||
|
}
|
||||||
|
date := timeutil.LocalDate(now, location).AddDate(0, 0, 2)
|
||||||
|
candidates := make([]report.Resolved, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
resolveReq := report.ResolveRequest{Now: now, Location: location}
|
||||||
|
if id == report.Daily {
|
||||||
|
resolveReq.Date = date
|
||||||
|
}
|
||||||
|
resolved, err := registry.Resolve(id, resolveReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates = append(candidates, resolved)
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
||||||
resolved := planned.Resolved
|
resolved := planned.Resolved
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
@@ -446,11 +471,9 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
|||||||
return report.Resolved{}, err
|
return report.Resolved{}, err
|
||||||
}
|
}
|
||||||
return registry.Resolve(id, report.ResolveRequest{
|
return registry.Resolve(id, report.ResolveRequest{
|
||||||
Now: now,
|
Now: now,
|
||||||
Location: location,
|
Location: location,
|
||||||
Date: req.Date,
|
Date: req.Date,
|
||||||
StormStart: req.StormStart,
|
|
||||||
StormEnd: req.StormEnd,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,391 +528,13 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
|||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
|
||||||
bundle := req.Collection.Bundle
|
|
||||||
if bundle == nil {
|
|
||||||
return nil, fmt.Errorf("collected weather bundle is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
store := req.Store
|
|
||||||
if store == nil {
|
|
||||||
defaultStore, err := defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
store = defaultStore
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(req.Resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
}, bundle)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
}, reportFacts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
|
||||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
|
||||||
ModuleSnapshot: moduleSnapshotPath,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
GeneratedTextRaw: paths.GeneratedTextRaw,
|
|
||||||
GeneratedTextResult: paths.GeneratedTextResult,
|
|
||||||
GeneratedText: paths.GeneratedText,
|
|
||||||
RenderContext: paths.RenderContext,
|
|
||||||
})
|
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
|
||||||
Metadata: promptMetadata(metadata),
|
|
||||||
Modules: moduleSnapshot,
|
|
||||||
RecentChanges: recentChanges,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
metadata.DataPackagePath = dataPackagePath
|
|
||||||
|
|
||||||
renderer := req.Renderer
|
|
||||||
if renderer == nil {
|
|
||||||
renderer = scriptorium.Runner{
|
|
||||||
Binary: req.Config.Scriptorium.Binary,
|
|
||||||
ConfigPath: req.Config.Scriptorium.ConfigPath,
|
|
||||||
Profile: req.Config.Scriptorium.Profile,
|
|
||||||
Timeout: req.Config.Scriptorium.Timeout,
|
|
||||||
ExtraArgs: req.Config.Scriptorium.ExtraArgs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{
|
|
||||||
PromptID: req.Resolved.Definition.PromptID,
|
|
||||||
DataPackagePath: dataPackagePath,
|
|
||||||
})
|
|
||||||
|
|
||||||
preflightPath := paths.Preflight
|
|
||||||
if renderResult != nil {
|
|
||||||
var err error
|
|
||||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metadata.PreflightPath = preflightPath
|
|
||||||
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
|
|
||||||
if metadataErr != nil {
|
|
||||||
return nil, metadataErr
|
|
||||||
}
|
|
||||||
if renderErr != nil {
|
|
||||||
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
|
||||||
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
|
|
||||||
}
|
|
||||||
return nil, renderErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
|
||||||
return generateTextTemplateReport(ctx, generatedReportRequest{
|
|
||||||
ReportRequest: req,
|
|
||||||
store: store,
|
|
||||||
paths: paths,
|
|
||||||
moduleSnapshot: moduleSnapshot,
|
|
||||||
moduleSnapshotPath: moduleSnapshotPath,
|
|
||||||
reportFacts: reportFacts,
|
|
||||||
dataPackage: dataPackage,
|
|
||||||
dataPackagePath: dataPackagePath,
|
|
||||||
briefingMetadata: briefingMetadata,
|
|
||||||
metadata: metadata,
|
|
||||||
metadataPath: metadataPath,
|
|
||||||
preflightPath: preflightPath,
|
|
||||||
priorSnapshot: priorSnapshot,
|
|
||||||
recentChanges: recentChanges,
|
|
||||||
renderResult: renderResult,
|
|
||||||
renderer: renderer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
|
|
||||||
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
|
|
||||||
PromptID: req.Resolved.Definition.PromptID,
|
|
||||||
DataPackagePath: dataPackagePath,
|
|
||||||
OutputPath: reportPath,
|
|
||||||
})
|
|
||||||
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Store: store,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
Metadata: metadata,
|
|
||||||
ManagedReportPath: reportPath,
|
|
||||||
OutputPath: req.OutputPath,
|
|
||||||
Notifier: req.Notifier,
|
|
||||||
GenerationErr: runErr,
|
|
||||||
noNotify: req.noNotify,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if finalizeResultEmpty(finalized) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return renderedReportResult(reportResultRequest{
|
|
||||||
moduleSnapshot: moduleSnapshot,
|
|
||||||
moduleSnapshotPath: moduleSnapshotPath,
|
|
||||||
dataPackage: dataPackage,
|
|
||||||
dataPackagePath: dataPackagePath,
|
|
||||||
preflightPath: preflightPath,
|
|
||||||
reportPath: reportPath,
|
|
||||||
finalized: finalized,
|
|
||||||
priorSnapshot: priorSnapshot,
|
|
||||||
recentChanges: recentChanges,
|
|
||||||
renderResult: renderResult,
|
|
||||||
runResult: runResult,
|
|
||||||
}), err
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderedReportResult(reportResultRequest{
|
|
||||||
moduleSnapshot: moduleSnapshot,
|
|
||||||
moduleSnapshotPath: moduleSnapshotPath,
|
|
||||||
dataPackage: dataPackage,
|
|
||||||
dataPackagePath: dataPackagePath,
|
|
||||||
preflightPath: preflightPath,
|
|
||||||
reportPath: reportPath,
|
|
||||||
finalized: finalized,
|
|
||||||
priorSnapshot: priorSnapshot,
|
|
||||||
recentChanges: recentChanges,
|
|
||||||
renderResult: renderResult,
|
|
||||||
runResult: runResult,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type generatedReportRequest struct {
|
|
||||||
ReportRequest
|
|
||||||
store state.Store
|
|
||||||
paths state.ArtifactPaths
|
|
||||||
moduleSnapshot module.Snapshot
|
|
||||||
moduleSnapshotPath string
|
|
||||||
reportFacts ReportFacts
|
|
||||||
dataPackage promptinput.Package
|
|
||||||
dataPackagePath string
|
|
||||||
briefingMetadata briefing.Metadata
|
|
||||||
metadata state.Metadata
|
|
||||||
metadataPath string
|
|
||||||
preflightPath string
|
|
||||||
priorSnapshot *state.PriorSnapshot
|
|
||||||
recentChanges []changes.Change
|
|
||||||
renderResult *scriptorium.RenderResult
|
|
||||||
renderer Renderer
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
|
||||||
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
|
|
||||||
PromptID: req.Resolved.Definition.PromptID,
|
|
||||||
DataPackagePath: req.dataPackagePath,
|
|
||||||
OutputPath: req.paths.GeneratedTextRaw,
|
|
||||||
})
|
|
||||||
generatedTextResultPath := req.paths.GeneratedTextResult
|
|
||||||
if structuredResult != nil {
|
|
||||||
var err error
|
|
||||||
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.metadata.GeneratedTextResultPath = generatedTextResultPath
|
|
||||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runErr != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
|
||||||
}
|
|
||||||
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
|
||||||
}
|
|
||||||
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.metadata.GeneratedTextPath = generatedTextPath
|
|
||||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
|
||||||
}
|
|
||||||
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.metadata.RenderContextPath = renderContextPath
|
|
||||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := handler.Render(renderContext)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
|
|
||||||
}
|
|
||||||
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Store: req.store,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
Metadata: req.metadata,
|
|
||||||
ManagedReportPath: reportPath,
|
|
||||||
OutputPath: req.OutputPath,
|
|
||||||
Notifier: req.Notifier,
|
|
||||||
noNotify: req.noNotify,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if finalizeResultEmpty(finalized) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return renderedReportResult(reportResultRequest{
|
|
||||||
moduleSnapshot: req.moduleSnapshot,
|
|
||||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
|
||||||
dataPackage: req.dataPackage,
|
|
||||||
dataPackagePath: req.dataPackagePath,
|
|
||||||
preflightPath: req.preflightPath,
|
|
||||||
reportPath: reportPath,
|
|
||||||
finalized: finalized,
|
|
||||||
priorSnapshot: req.priorSnapshot,
|
|
||||||
recentChanges: req.recentChanges,
|
|
||||||
renderResult: req.renderResult,
|
|
||||||
structuredRunResult: structuredResult,
|
|
||||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
|
||||||
generatedTextResultPath: generatedTextResultPath,
|
|
||||||
generatedTextPath: generatedTextPath,
|
|
||||||
renderContextPath: renderContextPath,
|
|
||||||
}), err
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderedReportResult(reportResultRequest{
|
|
||||||
moduleSnapshot: req.moduleSnapshot,
|
|
||||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
|
||||||
dataPackage: req.dataPackage,
|
|
||||||
dataPackagePath: req.dataPackagePath,
|
|
||||||
preflightPath: req.preflightPath,
|
|
||||||
reportPath: reportPath,
|
|
||||||
finalized: finalized,
|
|
||||||
priorSnapshot: req.priorSnapshot,
|
|
||||||
recentChanges: req.recentChanges,
|
|
||||||
renderResult: req.renderResult,
|
|
||||||
structuredRunResult: structuredResult,
|
|
||||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
|
||||||
generatedTextResultPath: generatedTextResultPath,
|
|
||||||
generatedTextPath: generatedTextPath,
|
|
||||||
renderContextPath: renderContextPath,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
|
|
||||||
return result.OutputPath == "" &&
|
|
||||||
result.NotificationPath == "" &&
|
|
||||||
result.MetadataPath == "" &&
|
|
||||||
result.Metadata.RunID == "" &&
|
|
||||||
result.Notification == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type reportResultRequest struct {
|
|
||||||
moduleSnapshot module.Snapshot
|
|
||||||
moduleSnapshotPath string
|
|
||||||
dataPackage promptinput.Package
|
|
||||||
dataPackagePath string
|
|
||||||
preflightPath string
|
|
||||||
reportPath string
|
|
||||||
finalized finalizeRenderedReportResult
|
|
||||||
priorSnapshot *state.PriorSnapshot
|
|
||||||
recentChanges []changes.Change
|
|
||||||
renderResult *scriptorium.RenderResult
|
|
||||||
runResult *scriptorium.RunResult
|
|
||||||
structuredRunResult *scriptorium.StructuredRunResult
|
|
||||||
generatedTextRawPath string
|
|
||||||
generatedTextResultPath string
|
|
||||||
generatedTextPath string
|
|
||||||
renderContextPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderedReportResult(req reportResultRequest) *ReportResult {
|
|
||||||
return &ReportResult{
|
|
||||||
ModuleSnapshot: req.moduleSnapshot,
|
|
||||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
|
||||||
DataPackage: req.dataPackage,
|
|
||||||
DataPackagePath: req.dataPackagePath,
|
|
||||||
PreflightPath: req.preflightPath,
|
|
||||||
ReportPath: req.reportPath,
|
|
||||||
OutputPath: req.finalized.OutputPath,
|
|
||||||
NotificationPath: req.finalized.NotificationPath,
|
|
||||||
Metadata: req.finalized.Metadata,
|
|
||||||
MetadataPath: req.finalized.MetadataPath,
|
|
||||||
PriorSnapshot: req.priorSnapshot,
|
|
||||||
RecentChanges: req.recentChanges,
|
|
||||||
RenderResult: req.renderResult,
|
|
||||||
RunResult: req.runResult,
|
|
||||||
StructuredRunResult: req.structuredRunResult,
|
|
||||||
GeneratedTextRawPath: req.generatedTextRawPath,
|
|
||||||
GeneratedTextResultPath: req.generatedTextResultPath,
|
|
||||||
GeneratedTextPath: req.generatedTextPath,
|
|
||||||
RenderContextPath: req.renderContextPath,
|
|
||||||
Notification: req.finalized.Notification,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type finalizeRenderedReportRequest struct {
|
type finalizeRenderedReportRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Store state.Store
|
Store state.Store
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Metadata state.Metadata
|
Metadata state.Metadata
|
||||||
|
MetadataPath string
|
||||||
|
ExecutionArtifact *state.PromptExecutionArtifact
|
||||||
ManagedReportPath string
|
ManagedReportPath string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
@@ -912,28 +557,35 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
|
|||||||
if req.ManagedReportPath == "" {
|
if req.ManagedReportPath == "" {
|
||||||
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
|
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
|
if req.ExecutionArtifact == nil {
|
||||||
|
return finalizeRenderedReportResult{}, fmt.Errorf("prompt execution artifact is required for report %q", req.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := req.Metadata
|
result := finalizeRenderedReportResult{Metadata: req.Metadata, MetadataPath: req.MetadataPath}
|
||||||
metadata.RenderedReportPath = req.ManagedReportPath
|
if req.OutputPath != "" && req.GenerationErr == nil {
|
||||||
outputPath := req.ManagedReportPath
|
if req.OutputPath != req.ManagedReportPath {
|
||||||
if req.OutputPath != "" {
|
|
||||||
outputPath = req.OutputPath
|
|
||||||
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
|
|
||||||
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
|
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
|
||||||
return finalizeRenderedReportResult{}, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
result.OutputPath = req.OutputPath
|
||||||
|
if err := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
|
||||||
|
paths.OutputPath = req.OutputPath
|
||||||
|
}); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.OutputPath = req.OutputPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata := req.Metadata
|
||||||
|
metadata.RenderedReportPath = req.ManagedReportPath
|
||||||
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
|
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return finalizeRenderedReportResult{}, err
|
return result, err
|
||||||
}
|
|
||||||
result := finalizeRenderedReportResult{
|
|
||||||
OutputPath: outputPath,
|
|
||||||
Metadata: metadata,
|
|
||||||
MetadataPath: metadataPath,
|
|
||||||
}
|
}
|
||||||
|
result.Metadata = metadata
|
||||||
|
result.MetadataPath = metadataPath
|
||||||
if req.GenerationErr != nil {
|
if req.GenerationErr != nil {
|
||||||
return result, req.GenerationErr
|
return result, req.GenerationErr
|
||||||
}
|
}
|
||||||
@@ -943,14 +595,21 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
|
|||||||
|
|
||||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
||||||
if notificationPath != "" {
|
if notificationPath != "" {
|
||||||
|
result.NotificationPath = notificationPath
|
||||||
|
result.Notification = notification
|
||||||
metadata.NotificationPath = notificationPath
|
metadata.NotificationPath = notificationPath
|
||||||
|
result.Metadata = metadata
|
||||||
|
if saveErr := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
|
||||||
|
paths.NotificationPath = notificationPath
|
||||||
|
}); saveErr != nil {
|
||||||
|
return result, saveErr
|
||||||
|
}
|
||||||
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
|
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
|
||||||
if saveErr != nil {
|
if saveErr != nil {
|
||||||
return finalizeRenderedReportResult{}, saveErr
|
return result, saveErr
|
||||||
}
|
}
|
||||||
result.Metadata = metadata
|
result.Metadata = metadata
|
||||||
result.MetadataPath = metadataPath
|
result.MetadataPath = metadataPath
|
||||||
result.NotificationPath = notificationPath
|
|
||||||
}
|
}
|
||||||
result.Notification = notification
|
result.Notification = notification
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1046,9 +705,6 @@ func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resol
|
|||||||
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
||||||
return config.DistributorTemplateValues{}, err
|
return config.DistributorTemplateValues{}, err
|
||||||
}
|
}
|
||||||
if resolved.Definition.ID == report.Storm {
|
|
||||||
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
|
|
||||||
}
|
|
||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1359,29 +1015,11 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
switch reportID {
|
switch reportID {
|
||||||
case report.Daily, report.Today, report.Tomorrow:
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
|
||||||
case report.Weekend:
|
|
||||||
return changes.CompareWeekend(previous, current, thresholds)
|
|
||||||
default:
|
default:
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
|
||||||
if result == nil {
|
|
||||||
return state.PreflightArtifact{}
|
|
||||||
}
|
|
||||||
return state.PreflightArtifact{
|
|
||||||
Command: append([]string(nil), result.Command...),
|
|
||||||
Stdout: result.Stdout,
|
|
||||||
Stderr: result.Stderr,
|
|
||||||
StdoutTruncated: result.StdoutTruncated,
|
|
||||||
StderrTruncated: result.StderrTruncated,
|
|
||||||
ExitCode: result.ExitCode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
85
internal/app/batch_execution_test.go
Normal file
85
internal/app/batch_execution_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
batch BatchKind
|
||||||
|
now string
|
||||||
|
wantPrompts int
|
||||||
|
}{
|
||||||
|
{name: "morning", batch: BatchMorning, now: "2026-05-29T08:00:00-05:00", wantPrompts: 3},
|
||||||
|
{name: "evening", batch: BatchEvening, now: "2026-05-29T18:00:00-05:00", wantPrompts: 2},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
now := mustParse(test.now)
|
||||||
|
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now}
|
||||||
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||||
|
}, prompts: map[string]promptexec.PromptInspection{}}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
|
||||||
|
}
|
||||||
|
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
|
return nil, errors.New("collection reached")
|
||||||
|
})
|
||||||
|
req.Executor = executor
|
||||||
|
req.Collector = collector
|
||||||
|
_, err = RunBatchDetailed(context.Background(), req)
|
||||||
|
if err == nil || err.Error() != "collection reached" {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != test.wantPrompts || len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyBatchReportPathsLeavesUnreachedPathsEmpty(t *testing.T) {
|
||||||
|
item := BatchReportResult{}
|
||||||
|
copyBatchReportPaths(&item, &ReportResult{
|
||||||
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "notificationPath"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("batch item includes unreached field %q:\n%s", omitted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
|
||||||
|
t.Fatalf("batch item omits reached paths:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectorFunc func(context.Context, collect.Request) (*collect.Result, error)
|
||||||
|
|
||||||
|
func (f collectorFunc) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
|
||||||
|
return f(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Collector = collectorFunc(nil)
|
||||||
@@ -55,19 +55,6 @@ func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
|||||||
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
|
|
||||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("planBatchRun() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range planned {
|
|
||||||
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
|
|
||||||
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
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)...)
|
||||||
|
|||||||
500
internal/app/batch_workflow_test.go
Normal file
500
internal/app/batch_workflow_test.go
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type assembledBatchExecutor struct {
|
||||||
|
definitions map[string]report.Definition
|
||||||
|
promptRequests []string
|
||||||
|
profileRequests []string
|
||||||
|
executeRequests []promptexec.ExecuteRequest
|
||||||
|
failures map[int]error
|
||||||
|
active int
|
||||||
|
maxActive int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssembledBatchExecutor() *assembledBatchExecutor {
|
||||||
|
definitions := make(map[string]report.Definition)
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
definitions[definition.PromptID] = definition
|
||||||
|
}
|
||||||
|
return &assembledBatchExecutor{definitions: definitions, failures: make(map[int]error)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||||
|
e.promptRequests = append(e.promptRequests, id+"@"+version)
|
||||||
|
definition, ok := e.definitions[id]
|
||||||
|
if !ok || definition.PromptVersion != version {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
|
||||||
|
}
|
||||||
|
return validPromptInspection(definition), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
e.profileRequests = append(e.profileRequests, id)
|
||||||
|
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
call := len(e.executeRequests)
|
||||||
|
e.executeRequests = append(e.executeRequests, req)
|
||||||
|
e.active++
|
||||||
|
if e.active > e.maxActive {
|
||||||
|
e.maxActive = e.active
|
||||||
|
}
|
||||||
|
defer func() { e.active-- }()
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
preparation := promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
|
||||||
|
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
|
||||||
|
}
|
||||||
|
if err := callback(preparation, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := e.failures[call]; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
definition := e.definitions[req.PromptID]
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath,
|
||||||
|
RawOutput: []byte(generatedTextForPrompt(req.PromptID)),
|
||||||
|
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type assembledBatchNotifier struct {
|
||||||
|
reportRequests []NotificationRequest
|
||||||
|
batchRequests []batchNotificationRequest
|
||||||
|
batchResult *NotificationResult
|
||||||
|
batchErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *assembledBatchNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.reportRequests = append(n.reportRequests, req)
|
||||||
|
return nil, errors.New("per-report notification must be suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *assembledBatchNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.batchRequests = append(n.batchRequests, req)
|
||||||
|
if n.batchErr != nil {
|
||||||
|
return nil, n.batchErr
|
||||||
|
}
|
||||||
|
if n.batchResult != nil {
|
||||||
|
result := *n.batchResult
|
||||||
|
if result.PipelineID == "" {
|
||||||
|
result.PipelineID = req.PipelineID
|
||||||
|
}
|
||||||
|
if result.BundleID == "" {
|
||||||
|
result.BundleID = req.BundleID
|
||||||
|
}
|
||||||
|
if result.IdempotencyKey == "" {
|
||||||
|
result.IdempotencyKey = req.IdempotencyKey
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
return &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
batch BatchKind
|
||||||
|
now time.Time
|
||||||
|
wantIDs []report.ID
|
||||||
|
wantCopies []string
|
||||||
|
}{
|
||||||
|
{name: "morning", batch: BatchMorning, now: workflowTime("2026-05-29T08:00:00-05:00"), wantIDs: []report.ID{report.Today, report.Tomorrow, report.Daily}, wantCopies: []string{"today.md", "tomorrow.md", "daily-2026-05-31.md"}},
|
||||||
|
{name: "evening", batch: BatchEvening, now: workflowTime("2026-05-29T18:00:00-05:00"), wantIDs: []report.ID{report.Tomorrow, report.Daily}, wantCopies: []string{"tomorrow.md", "daily-2026-05-31.md"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := newAssembledBatchExecutor()
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || result.Total != len(test.wantIDs) || result.Succeeded != len(test.wantIDs) || result.Failed != 0 {
|
||||||
|
t.Fatalf("collection/summary = %d/%d/%d/%d", collector.calls, result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
|
if len(executor.executeRequests) != len(test.wantIDs) || executor.maxActive != 1 {
|
||||||
|
t.Fatalf("executor calls/max active = %d/%d, want %d/1", len(executor.executeRequests), executor.maxActive, len(test.wantIDs))
|
||||||
|
}
|
||||||
|
if len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("profile inspections = %#v, want one shared profile inspection", executor.profileRequests)
|
||||||
|
}
|
||||||
|
for index, item := range result.Reports {
|
||||||
|
if item.ReportID != test.wantIDs[index] || item.Status != "succeeded" {
|
||||||
|
t.Fatalf("report %d = %s/%s, want %s/succeeded", index, item.ReportID, item.Status, test.wantIDs[index])
|
||||||
|
}
|
||||||
|
if executor.executeRequests[index].PromptID != item.PromptID {
|
||||||
|
t.Fatalf("execution %d prompt = %q, want item prompt %q", index, executor.executeRequests[index].PromptID, item.PromptID)
|
||||||
|
}
|
||||||
|
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.ReportPath == "" || item.OutputPath == "" || item.MetadataPath == "" {
|
||||||
|
t.Fatalf("successful report paths = %#v", item)
|
||||||
|
}
|
||||||
|
assertBatchItemMatchesMetadata(t, item)
|
||||||
|
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
|
||||||
|
t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index])
|
||||||
|
}
|
||||||
|
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
|
||||||
|
managed, readErr := os.ReadFile(item.ReportPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read managed report: %v", readErr)
|
||||||
|
}
|
||||||
|
copied, readErr := os.ReadFile(item.OutputPath)
|
||||||
|
if readErr != nil || !bytes.Equal(managed, copied) {
|
||||||
|
t.Fatalf("output copy mismatch/error = %v", readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := newAssembledBatchExecutor()
|
||||||
|
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
|
||||||
|
Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || len(executor.executeRequests) != 3 || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||||
|
t.Fatalf("collection/execution/summary = %d/%d/%d/%d/%d", collector.calls, len(executor.executeRequests), result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
|
if len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 || result.Notification == nil || result.Notification.Status != "skipped" {
|
||||||
|
t.Fatalf("notification state = reports %d batches %d result %#v", len(notifier.reportRequests), len(notifier.batchRequests), result.Notification)
|
||||||
|
}
|
||||||
|
for index, item := range result.Reports {
|
||||||
|
if index == 1 {
|
||||||
|
if item.ReportID != report.Tomorrow || item.Status != "failed" || !strings.Contains(item.Error, string(promptexec.Capacity)) {
|
||||||
|
t.Fatalf("failed item = %#v", item)
|
||||||
|
}
|
||||||
|
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.MetadataPath == "" || item.ReportPath != "" || item.OutputPath != "" {
|
||||||
|
t.Fatalf("failed item reached paths = %#v", item)
|
||||||
|
}
|
||||||
|
assertBatchItemMatchesMetadata(t, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Status != "succeeded" || item.ReportPath == "" || item.OutputPath == "" {
|
||||||
|
t.Fatalf("continued item %d = %#v", index, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
||||||
|
t.Run("disabled", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
|
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("batch disabled", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
cfg.Notify.Distributor.Batch.Enabled = false
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
|
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all success", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
}}
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
|
||||||
|
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
managedPaths := make(map[string]struct{}, len(result.Reports))
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
managedPaths[item.ReportPath] = struct{}{}
|
||||||
|
if item.NotificationPath != "" {
|
||||||
|
t.Fatalf("report item contains per-report notification path: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request := notifier.batchRequests[0]
|
||||||
|
if len(request.IncludedReports) != len(result.Reports) {
|
||||||
|
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
|
||||||
|
}
|
||||||
|
for _, file := range request.Files {
|
||||||
|
if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
|
||||||
|
t.Fatalf("notification file = %#v, want managed Markdown source", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-notification-run" || artifact.RunStatus == nil || len(artifact.Reports) != len(result.Reports) {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upload failure", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" || result.Notification.Path == "" {
|
||||||
|
t.Fatalf("result = %#v, want successful reports and failed notification", result)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status != "succeeded" {
|
||||||
|
t.Fatalf("report item = %#v, want success despite notification failure", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status report", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", Status: "accepted", UploadStatus: "accepted",
|
||||||
|
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
}}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31", "2026-06-01")
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
seenRuns := make(map[string]struct{})
|
||||||
|
seenDebug := make(map[string]struct{})
|
||||||
|
dailyDates := make(map[string]BatchReportResult)
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if _, exists := seenRuns[item.RunID]; exists {
|
||||||
|
t.Fatalf("duplicate run ID %q", item.RunID)
|
||||||
|
}
|
||||||
|
seenRuns[item.RunID] = struct{}{}
|
||||||
|
if _, exists := seenDebug[item.LLMDebugPath]; exists {
|
||||||
|
t.Fatalf("duplicate debug path %q", item.LLMDebugPath)
|
||||||
|
}
|
||||||
|
seenDebug[item.LLMDebugPath] = struct{}{}
|
||||||
|
if item.ReportID == report.Daily {
|
||||||
|
date := item.ValidPeriod.Start.In(mustLoadTestLocation(t, "America/Chicago")).Format("2006-01-02")
|
||||||
|
dailyDates[date] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, date := range []string{"2026-05-31", "2026-06-01"} {
|
||||||
|
item, ok := dailyDates[date]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daily items = %#v, want %s", dailyDates, date)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(item.RunID, "_daily_"+date) || item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") {
|
||||||
|
t.Fatalf("daily identity/output = %q/%q", item.RunID, item.OutputPath)
|
||||||
|
}
|
||||||
|
wantDebugPrefix := filepath.Join(debugRoot, "daily", date, item.RunID)
|
||||||
|
if item.LLMDebugPath != wantDebugPrefix {
|
||||||
|
t.Fatalf("daily debug path = %q, want %q", item.LLMDebugPath, wantDebugPrefix)
|
||||||
|
}
|
||||||
|
assertBatchPathsExist(t, filepath.Join(item.LLMDebugPath, "preparation.json"), filepath.Join(item.LLMDebugPath, "execution.json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedUsesPriorSnapshotsForPromptPackages(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
firstBundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
setWorkflowTemperatures(&firstBundle, 45)
|
||||||
|
first, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || first.Failed != 0 {
|
||||||
|
t.Fatalf("first result/error = %#v/%v", first, err)
|
||||||
|
}
|
||||||
|
secondBundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
setWorkflowTemperatures(&secondBundle, 85)
|
||||||
|
second, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || second.Failed != 0 || len(second.Reports) != len(first.Reports) {
|
||||||
|
t.Fatalf("second result/error = %#v/%v", second, err)
|
||||||
|
}
|
||||||
|
for _, item := range second.Reports {
|
||||||
|
pkg := loadBatchDataPackage(t, item.DataPackagePath)
|
||||||
|
if len(pkg.RecentChanges.Items) == 0 {
|
||||||
|
t.Fatalf("report %s data package has no changes from prior snapshot", item.ReportID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledBatchConfig(t *testing.T, notify bool) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = notify
|
||||||
|
cfg.Notify.Distributor.Batch.Enabled = notify
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledBatchBundle(t *testing.T, dates ...string) weatherdata.Bundle {
|
||||||
|
t.Helper()
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
for _, date := range dates {
|
||||||
|
periods := fullDayPeriods(t, date, location)
|
||||||
|
for index := range periods {
|
||||||
|
temperature := float64(60 + index)
|
||||||
|
periods[index].TemperatureF = &temperature
|
||||||
|
periods[index].TextDescription = "Partly cloudy"
|
||||||
|
}
|
||||||
|
bundle.Hourly.Periods = append(bundle.Hourly.Periods, periods...)
|
||||||
|
}
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedTextForPrompt(promptID string) string {
|
||||||
|
switch promptID {
|
||||||
|
case "weather.today_generated_text":
|
||||||
|
return validTodayWorkflowJSON()
|
||||||
|
case "weather.tomorrow_generated_text":
|
||||||
|
return validTomorrowWorkflowJSON()
|
||||||
|
case "weather.daily_generated_text":
|
||||||
|
return validDailyWorkflowJSON()
|
||||||
|
case "weather.hourly_generated_text":
|
||||||
|
return validHourlyWorkflowJSON()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchPathsExist(t *testing.T, paths ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected path %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(item.MetadataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read metadata %q: %v", item.MetadataPath, err)
|
||||||
|
}
|
||||||
|
var metadata state.Metadata
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
t.Fatalf("decode metadata %q: %v", item.MetadataPath, err)
|
||||||
|
}
|
||||||
|
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
|
||||||
|
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
|
||||||
|
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath ||
|
||||||
|
item.NotificationPath != metadata.NotificationPath {
|
||||||
|
t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBatchNotificationArtifact(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read batch notification artifact: %v", err)
|
||||||
|
}
|
||||||
|
var artifact state.BatchDistributorNotificationArtifact
|
||||||
|
if err := json.Unmarshal(data, &artifact); err != nil {
|
||||||
|
t.Fatalf("decode batch notification artifact: %v", err)
|
||||||
|
}
|
||||||
|
return artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBatchDataPackage(t *testing.T, path string) promptinput.Package {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read data package: %v", err)
|
||||||
|
}
|
||||||
|
pkg, err := promptinput.LoadYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
552
internal/app/prompt_artifact_paths_test.go
Normal file
552
internal/app/prompt_artifact_paths_test.go
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
failPromptExecution = "prompt execution"
|
||||||
|
failMetadata = "metadata"
|
||||||
|
failGeneratedText = "generated text"
|
||||||
|
failRenderContext = "render context"
|
||||||
|
failRenderedReportPath = "rendered report path"
|
||||||
|
failDistributorNotification = "distributor notification"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingPersistenceStore struct {
|
||||||
|
state.Store
|
||||||
|
failOperation string
|
||||||
|
failExecutionCall int
|
||||||
|
failMetadataCall int
|
||||||
|
executionCalls int
|
||||||
|
metadataCalls int
|
||||||
|
renderedReportPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SavePromptExecution(ctx context.Context, resolved report.Resolved, artifact state.PromptExecutionArtifact) (string, error) {
|
||||||
|
s.executionCalls++
|
||||||
|
if s.failOperation == failPromptExecution && (s.failExecutionCall == 0 || s.executionCalls == s.failExecutionCall) {
|
||||||
|
return "", errors.New("injected prompt execution persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SavePromptExecution(ctx, resolved, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
|
||||||
|
if s.failOperation == failGeneratedText {
|
||||||
|
return "", errors.New("injected generated text persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveGeneratedText(ctx, resolved, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
|
||||||
|
if s.failOperation == failRenderContext {
|
||||||
|
return "", errors.New("injected render context persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveRenderContext(ctx, resolved, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
|
||||||
|
if s.failOperation == failRenderedReportPath {
|
||||||
|
return s.renderedReportPath, nil
|
||||||
|
}
|
||||||
|
return s.Store.PrepareRenderedReport(ctx, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveDistributorNotification(ctx context.Context, resolved report.Resolved, artifact state.DistributorNotificationArtifact) (string, error) {
|
||||||
|
if s.failOperation == failDistributorNotification {
|
||||||
|
return "", errors.New("injected notification persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveDistributorNotification(ctx, resolved, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
|
||||||
|
s.metadataCalls++
|
||||||
|
if s.failOperation == failMetadata && s.metadataCalls == s.failMetadataCall {
|
||||||
|
return "", errors.New("injected metadata persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveMetadata(ctx, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
type artifactPathExecutor struct {
|
||||||
|
beforePreparationErr error
|
||||||
|
afterPreparationErr error
|
||||||
|
validation promptexec.ValidationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||||
|
return promptexec.ProfileInspection{}, errors.New("unexpected inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
if e.beforePreparationErr != nil {
|
||||||
|
return nil, e.beforePreparationErr
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
if err := callback(promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "test",
|
||||||
|
ModelName: "test-model", DataPackagePath: req.DataPackagePath, StartedAt: now, EndedAt: now,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.afterPreparationErr != nil {
|
||||||
|
return nil, e.afterPreparationErr
|
||||||
|
}
|
||||||
|
validation := e.validation
|
||||||
|
if validation == "" {
|
||||||
|
validation = promptexec.ValidationPassed
|
||||||
|
}
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath,
|
||||||
|
RawOutput: []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`),
|
||||||
|
Validation: promptexec.NewValidation(validation, "json_schema", "daily.generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type successfulNotifier struct{}
|
||||||
|
|
||||||
|
func (successfulNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||||
|
return &NotificationResult{RunID: "notification-run", Status: "succeeded", UploadStatus: "accepted"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingNotifier struct{}
|
||||||
|
|
||||||
|
func (failingNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||||
|
return nil, errors.New("injected notification failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failOperation string
|
||||||
|
failMetadataCall int
|
||||||
|
outputCopy bool
|
||||||
|
notify bool
|
||||||
|
want reachedPromptArtifacts
|
||||||
|
}{
|
||||||
|
{name: "preparation then metadata", failOperation: failMetadata, failMetadataCall: 1, want: reachedPromptArtifacts{preparation: true}},
|
||||||
|
{name: "raw output then execution", failOperation: failPromptExecution, want: reachedPromptArtifacts{preparation: true, metadata: true, raw: true}},
|
||||||
|
{name: "execution then metadata", failOperation: failMetadata, failMetadataCall: 2, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true}},
|
||||||
|
{name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}},
|
||||||
|
{name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}},
|
||||||
|
{name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}},
|
||||||
|
{name: "output copy then metadata", failOperation: failMetadata, failMetadataCall: 5, outputCopy: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}},
|
||||||
|
{name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall}
|
||||||
|
req.Store = store
|
||||||
|
if test.outputCopy {
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.notify {
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePromptReportFailureReceiptsExposeReachedPaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
executor artifactPathExecutor
|
||||||
|
want reachedPromptArtifacts
|
||||||
|
wantExecutionStatus state.PromptExecutionStatus
|
||||||
|
wantExecutionPaths state.PromptExecutionPaths
|
||||||
|
wantRawExecution bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preparation failure",
|
||||||
|
executor: artifactPathExecutor{beforePreparationErr: promptexec.NewError(promptexec.Generation, "prepare failed", nil)},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, metadata: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operational execution failure",
|
||||||
|
executor: artifactPathExecutor{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider failed", nil)},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true},
|
||||||
|
wantExecutionStatus: state.PromptExecutionFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed validation rejection",
|
||||||
|
executor: artifactPathExecutor{validation: promptexec.ValidationFailed},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
|
||||||
|
wantExecutionStatus: state.PromptExecutionValidationRejected,
|
||||||
|
wantRawExecution: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, test.executor)
|
||||||
|
if test.wantRawExecution {
|
||||||
|
test.wantExecutionPaths.RawOutputPath = paths.GeneratedTextRaw
|
||||||
|
}
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.want)
|
||||||
|
if test.wantExecutionStatus != "" {
|
||||||
|
artifact, loadErr := req.Store.LoadPromptExecution(context.Background(), result.ExecutionPath)
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("LoadPromptExecution() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
if artifact.Status != test.wantExecutionStatus || artifact.Paths != test.wantExecutionPaths {
|
||||||
|
t.Fatalf("execution outcome/paths = %q/%#v, want %q/%#v", artifact.Status, artifact.Paths, test.wantExecutionStatus, test.wantExecutionPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletedExecutionArtifactTracksDownstreamLifecycle(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generatePromptReport() error = %v", err)
|
||||||
|
}
|
||||||
|
want := state.PromptExecutionPaths{
|
||||||
|
RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText,
|
||||||
|
RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport,
|
||||||
|
OutputPath: paths.output, NotificationPath: paths.Notification,
|
||||||
|
}
|
||||||
|
assertPersistedExecutionPaths(t, req.Store, result.ExecutionPath, want)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(result.ExecutionPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read execution artifact: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"Showers are possible during the selected day", `"rawOutput":`, `"debug":`,
|
||||||
|
`"renderedMessages":`, `"structuredSchema":`, `"endpoint":`, `"parametersJSON":`,
|
||||||
|
"credential", "secret-value",
|
||||||
|
} {
|
||||||
|
if strings.Contains(text, forbidden) {
|
||||||
|
t.Fatalf("execution artifact contains unsafe generated or provider detail %q:\n%s", forbidden, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failOperation string
|
||||||
|
failExecutionCall int
|
||||||
|
failMetadataCall int
|
||||||
|
requestOutput bool
|
||||||
|
failOutputCopy bool
|
||||||
|
notify bool
|
||||||
|
notificationFailure bool
|
||||||
|
wantExecution reachedExecutionArtifacts
|
||||||
|
wantResult reachedPromptArtifacts
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normalized text write", failOperation: failGeneratedText,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalized text checkpoint", failOperation: failPromptExecution, failExecutionCall: 2,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalized text metadata", failOperation: failMetadata, failMetadataCall: 3,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context write", failOperation: failRenderContext,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context checkpoint", failOperation: failPromptExecution, failExecutionCall: 3,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context metadata", failOperation: failMetadata, failMetadataCall: 4,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed report write", failOperation: failRenderedReportPath,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed report checkpoint", failOperation: failPromptExecution, failExecutionCall: 4,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy write", requestOutput: true, failOutputCopy: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification artifact write", failOperation: failDistributorNotification, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification checkpoint", failOperation: failPromptExecution, failExecutionCall: 6, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification metadata", failOperation: failMetadata, failMetadataCall: 6, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification operation", requestOutput: true, notify: true, notificationFailure: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
store := &failingPersistenceStore{
|
||||||
|
Store: req.Store, failOperation: test.failOperation,
|
||||||
|
failExecutionCall: test.failExecutionCall, failMetadataCall: test.failMetadataCall,
|
||||||
|
}
|
||||||
|
if test.failOperation == failRenderedReportPath {
|
||||||
|
store.renderedReportPath = t.TempDir()
|
||||||
|
}
|
||||||
|
req.Store = store
|
||||||
|
if test.requestOutput {
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.failOutputCopy {
|
||||||
|
blocker := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write output blocker: %v", err)
|
||||||
|
}
|
||||||
|
req.OutputPath = filepath.Join(blocker, "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.notify {
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
}
|
||||||
|
if test.notificationFailure {
|
||||||
|
req.Notifier = failingNotifier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.wantResult)
|
||||||
|
assertPersistedExecutionPaths(t, store, result.ExecutionPath, executionPathsFor(paths, test.wantExecution))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type reachedExecutionArtifacts struct {
|
||||||
|
raw bool
|
||||||
|
normalized bool
|
||||||
|
renderContext bool
|
||||||
|
report bool
|
||||||
|
output bool
|
||||||
|
notification bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths {
|
||||||
|
result := state.PromptExecutionPaths{}
|
||||||
|
if reached.raw {
|
||||||
|
result.RawOutputPath = paths.GeneratedTextRaw
|
||||||
|
}
|
||||||
|
if reached.normalized {
|
||||||
|
result.GeneratedTextPath = paths.GeneratedText
|
||||||
|
}
|
||||||
|
if reached.renderContext {
|
||||||
|
result.RenderContextPath = paths.RenderContext
|
||||||
|
}
|
||||||
|
if reached.report {
|
||||||
|
result.RenderedReportPath = paths.RenderedReport
|
||||||
|
}
|
||||||
|
if reached.output {
|
||||||
|
result.OutputPath = paths.output
|
||||||
|
}
|
||||||
|
if reached.notification {
|
||||||
|
result.NotificationPath = paths.Notification
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPersistedExecutionPaths(t *testing.T, store state.Store, path string, want state.PromptExecutionPaths) {
|
||||||
|
t.Helper()
|
||||||
|
artifact, err := store.LoadPromptExecution(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPromptExecution() error = %v", err)
|
||||||
|
}
|
||||||
|
if artifact.Status != state.PromptExecutionSucceeded || artifact.Validation == nil || artifact.Validation.Status != promptexec.ValidationPassed {
|
||||||
|
t.Fatalf("execution outcome changed after downstream write: %#v", artifact)
|
||||||
|
}
|
||||||
|
if artifact.Provenance == nil || artifact.Provenance.RunID != "provider-run" || artifact.Provenance.PromptHash != "prompt-hash" {
|
||||||
|
t.Fatalf("execution provenance changed after downstream write: %#v", artifact.Provenance)
|
||||||
|
}
|
||||||
|
if artifact.Paths != want {
|
||||||
|
t.Fatalf("execution paths = %#v, want %#v", artifact.Paths, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptArtifactPaths struct {
|
||||||
|
state.ArtifactPaths
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
type reachedPromptArtifacts struct {
|
||||||
|
preparation bool
|
||||||
|
execution bool
|
||||||
|
metadata bool
|
||||||
|
raw bool
|
||||||
|
normalized bool
|
||||||
|
renderContext bool
|
||||||
|
report bool
|
||||||
|
output bool
|
||||||
|
notification bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptArtifactRequest(t *testing.T, executor promptexec.Executor) (promptReportRequest, promptArtifactPaths) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
bundleData, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read daily fixture: %v", err)
|
||||||
|
}
|
||||||
|
var bundle weatherdata.Bundle
|
||||||
|
if err := json.Unmarshal(bundleData, &bundle); err != nil {
|
||||||
|
t.Fatalf("decode daily fixture: %v", err)
|
||||||
|
}
|
||||||
|
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := filesystemStore.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
debugWriter, err := state.NewPromptDebugWriter("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
return promptReportRequest{
|
||||||
|
GenerateRequest: GenerateRequest{Config: cfg, Report: ReportDaily, Executor: executor, Store: filesystemStore},
|
||||||
|
Resolved: resolved, Collection: collect.Result{Bundle: &bundle},
|
||||||
|
Inspection: PromptInspectionResult{
|
||||||
|
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", ProfileID: "test-profile", BackendID: "test", ModelName: "test-model",
|
||||||
|
},
|
||||||
|
DebugWriter: debugWriter, noNotify: true,
|
||||||
|
}, promptArtifactPaths{ArtifactPaths: paths}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertReachedPromptArtifacts(t *testing.T, result *ReportResult, paths promptArtifactPaths, want reachedPromptArtifacts) {
|
||||||
|
t.Helper()
|
||||||
|
if result.ModuleSnapshotPath != paths.ModuleSnapshot || result.DataPackagePath != paths.DataPackage {
|
||||||
|
t.Fatalf("base paths = module %q data %q, want %q and %q", result.ModuleSnapshotPath, result.DataPackagePath, paths.ModuleSnapshot, paths.DataPackage)
|
||||||
|
}
|
||||||
|
if result.Metadata.ModuleSnapshotPath != paths.ModuleSnapshot || result.Metadata.DataPackagePath != paths.DataPackage || result.Metadata.MetadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("metadata base paths = %#v, want reached module/data paths and metadata destination", result.Metadata)
|
||||||
|
}
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
metadataGot string
|
||||||
|
inMetadata bool
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"preparation", result.PreparationPath, result.Metadata.PreparationPath, true, paths.Preparation, want.preparation},
|
||||||
|
{"execution", result.ExecutionPath, result.Metadata.ExecutionPath, true, paths.Execution, want.execution},
|
||||||
|
{"metadata", result.MetadataPath, "", false, paths.Metadata, want.metadata},
|
||||||
|
{"raw", result.GeneratedTextRawPath, result.Metadata.GeneratedTextRawPath, true, paths.GeneratedTextRaw, want.raw},
|
||||||
|
{"normalized", result.GeneratedTextPath, result.Metadata.GeneratedTextPath, true, paths.GeneratedText, want.normalized},
|
||||||
|
{"render context", result.RenderContextPath, result.Metadata.RenderContextPath, true, paths.RenderContext, want.renderContext},
|
||||||
|
{"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report},
|
||||||
|
{"output", result.OutputPath, "", false, paths.output, want.output},
|
||||||
|
{"notification", result.NotificationPath, result.Metadata.NotificationPath, true, paths.Notification, want.notification},
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.want && check.got != check.path {
|
||||||
|
t.Errorf("%s path = %q, want reached path %q", check.name, check.got, check.path)
|
||||||
|
}
|
||||||
|
if check.want && check.inMetadata && check.metadataGot != check.path {
|
||||||
|
t.Errorf("metadata %s path = %q, want reached path %q", check.name, check.metadataGot, check.path)
|
||||||
|
}
|
||||||
|
if !check.want && check.got != "" {
|
||||||
|
t.Errorf("%s path = %q, want empty because artifact was not reached", check.name, check.got)
|
||||||
|
}
|
||||||
|
if !check.want && check.inMetadata && check.metadataGot != "" {
|
||||||
|
t.Errorf("metadata %s path = %q, want empty because artifact was not reached", check.name, check.metadataGot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
423
internal/app/prompt_generate.go
Normal file
423
internal/app/prompt_generate.go
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promptReportRequest struct {
|
||||||
|
GenerateRequest
|
||||||
|
Resolved report.Resolved
|
||||||
|
Collection collect.Result
|
||||||
|
Inspection PromptInspectionResult
|
||||||
|
DebugWriter *state.PromptDebugWriter
|
||||||
|
noNotify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
|
||||||
|
workflow, err := newPromptReportWorkflow(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := workflow.buildInputs(); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
execution, executeErr := workflow.executePrompt()
|
||||||
|
if executeErr != nil {
|
||||||
|
return workflow.result, workflow.handleExecutionFailure(executeErr)
|
||||||
|
}
|
||||||
|
if execution == nil {
|
||||||
|
err := promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)
|
||||||
|
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
|
||||||
|
return workflow.result, saveErr
|
||||||
|
}
|
||||||
|
return workflow.result, workflow.reportError("execute prompt", err)
|
||||||
|
}
|
||||||
|
if err := workflow.persistExecutionDebug(*execution); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
|
||||||
|
err := promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)
|
||||||
|
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
|
||||||
|
return workflow.result, saveErr
|
||||||
|
}
|
||||||
|
return workflow.result, workflow.reportError("validate prompt execution", err)
|
||||||
|
}
|
||||||
|
if err := workflow.persistCompletedExecution(*execution); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
if execution.Validation.Status == promptexec.ValidationFailed {
|
||||||
|
err := promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)
|
||||||
|
return workflow.result, workflow.reportError("validate prompt execution", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := workflow.persistGeneratedContent(execution.RawOutput)
|
||||||
|
if err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
return workflow.finalizeReport(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptReportWorkflow struct {
|
||||||
|
ctx context.Context
|
||||||
|
req promptReportRequest
|
||||||
|
store state.Store
|
||||||
|
result *ReportResult
|
||||||
|
metadata state.Metadata
|
||||||
|
briefingMetadata briefing.Metadata
|
||||||
|
reportFacts ReportFacts
|
||||||
|
moduleSnapshot module.Snapshot
|
||||||
|
dataPackageBytes []byte
|
||||||
|
handler generatedtext.Handler
|
||||||
|
executionArtifact state.PromptExecutionArtifact
|
||||||
|
debugRef state.PromptDebugRef
|
||||||
|
prepared bool
|
||||||
|
callbackFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, error) {
|
||||||
|
if req.Collection.Bundle == nil {
|
||||||
|
return nil, fmt.Errorf("collected weather bundle is required")
|
||||||
|
}
|
||||||
|
store := req.Store
|
||||||
|
var err error
|
||||||
|
if store == nil {
|
||||||
|
store, err = defaultStore(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &promptReportWorkflow{ctx: ctx, req: req, store: store}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) buildInputs() error {
|
||||||
|
paths, err := w.store.Paths(w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result = &ReportResult{}
|
||||||
|
priorSnapshot, err := w.store.FindPriorSnapshot(w.ctx, w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
|
||||||
|
if err != nil {
|
||||||
|
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build report facts", err)
|
||||||
|
}
|
||||||
|
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
|
||||||
|
if err != nil {
|
||||||
|
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build module snapshot", err)
|
||||||
|
}
|
||||||
|
moduleSnapshotPath, err := w.store.SaveModuleSnapshot(w.ctx, w.req.Resolved, w.moduleSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ModuleSnapshot = w.moduleSnapshot
|
||||||
|
w.result.ModuleSnapshotPath = moduleSnapshotPath
|
||||||
|
w.result.PriorSnapshot = priorSnapshot
|
||||||
|
|
||||||
|
recent, err := recentChanges(w.ctx, w.store, priorSnapshot, w.req.Resolved.Definition.ID, w.moduleSnapshot, w.req.Config.RecentChange)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.RecentChanges = recent
|
||||||
|
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
|
||||||
|
w.metadata = state.BuildPromptMetadataFromBriefingMetadata(w.req.Resolved, w.briefingMetadata, state.ArtifactPaths{
|
||||||
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
|
Metadata: paths.Metadata,
|
||||||
|
})
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||||
|
Metadata: promptMetadata(w.metadata), Modules: w.moduleSnapshot, RecentChanges: recent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("build data package", err)
|
||||||
|
}
|
||||||
|
w.dataPackageBytes, err = promptinput.MarshalYAML(dataPackage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataPackagePath, err := w.store.SaveDataPackageBytes(w.ctx, w.req.Resolved, w.dataPackageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.metadata.DataPackagePath = dataPackagePath
|
||||||
|
w.result.DataPackage = dataPackage
|
||||||
|
w.result.DataPackagePath = dataPackagePath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("lookup generated text catalog", err)
|
||||||
|
}
|
||||||
|
w.debugRef = state.PromptDebugRef{
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.metadata.RunID,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
|
||||||
|
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackageBytes,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, CaptureDebug: w.req.DebugWriter.Enabled(),
|
||||||
|
}, w.persistPreparation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistPreparation(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
artifact := state.PromptPreparationArtifact{
|
||||||
|
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationSucceeded,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, Preparation: &preparation,
|
||||||
|
StartedAt: preparation.StartedAt, EndedAt: preparation.EndedAt, Duration: preparation.Duration,
|
||||||
|
}
|
||||||
|
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.prepared = true
|
||||||
|
w.result.PreparationPath = path
|
||||||
|
w.metadata.PreparationPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
debugPath, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
|
||||||
|
if err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return promptDebugWriteError(err)
|
||||||
|
}
|
||||||
|
if debugPath != "" {
|
||||||
|
w.result.LLMDebugPath = debugPath
|
||||||
|
}
|
||||||
|
if err := w.saveMetadata(); err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) handleExecutionFailure(executeErr error) error {
|
||||||
|
if w.callbackFailed {
|
||||||
|
return executeErr
|
||||||
|
}
|
||||||
|
executeErr = classifiedPromptError("prompt execution failed", executeErr)
|
||||||
|
if !w.prepared {
|
||||||
|
if err := w.persistPreparationFailure(executeErr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.reportError("prepare prompt", executeErr)
|
||||||
|
}
|
||||||
|
if promptexec.CategoryOf(executeErr) != "" {
|
||||||
|
if err := w.persistOperationalExecutionFailure(executeErr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.reportError("execute prompt", executeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistPreparationFailure(executeErr error) error {
|
||||||
|
startedAt, endedAt := time.Now(), time.Now()
|
||||||
|
artifact := state.PromptPreparationArtifact{
|
||||||
|
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationFailed,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, StartedAt: startedAt, EndedAt: endedAt,
|
||||||
|
Error: state.NewPromptArtifactError(executeErr),
|
||||||
|
}
|
||||||
|
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.PreparationPath = path
|
||||||
|
w.metadata.PreparationPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistOperationalExecutionFailure(executeErr error) error {
|
||||||
|
artifact := failedPromptExecutionArtifact(w.req.Resolved, w.metadata, w.req.Inspection, executeErr)
|
||||||
|
path, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ExecutionPath = path
|
||||||
|
w.metadata.ExecutionPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistExecutionDebug(execution promptexec.Execution) error {
|
||||||
|
debugPath, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("write prompt debug", promptDebugWriteError(err))
|
||||||
|
}
|
||||||
|
if debugPath != "" {
|
||||||
|
w.result.LLMDebugPath = debugPath
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistCompletedExecution(execution promptexec.Execution) error {
|
||||||
|
rawPath, err := w.store.SaveGeneratedTextRaw(w.ctx, w.req.Resolved, execution.RawOutput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.GeneratedTextRawPath = rawPath
|
||||||
|
w.metadata.GeneratedTextRawPath = rawPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.executionArtifact = state.PromptExecutionArtifact{
|
||||||
|
SchemaVersion: state.PromptExecutionSchemaVersion,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
Provenance: ptr(state.PromptExecutionProvenanceFrom(execution)), Validation: &execution.Validation,
|
||||||
|
Paths: state.PromptExecutionPaths{RawOutputPath: rawPath},
|
||||||
|
StartedAt: execution.StartedAt, EndedAt: execution.EndedAt, Duration: execution.Duration,
|
||||||
|
}
|
||||||
|
if execution.Validation.Status == promptexec.ValidationPassed {
|
||||||
|
w.executionArtifact.Status = state.PromptExecutionSucceeded
|
||||||
|
} else {
|
||||||
|
w.executionArtifact.Status = state.PromptExecutionValidationRejected
|
||||||
|
}
|
||||||
|
executionPath, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, w.executionArtifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ExecutionPath = executionPath
|
||||||
|
w.metadata.ExecutionPath = executionPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistGeneratedContent(raw []byte) ([]byte, error) {
|
||||||
|
generatedText, normalized, err := w.handler.Validate(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("validate generated text", err)
|
||||||
|
}
|
||||||
|
generatedTextPath, err := w.store.SaveGeneratedText(w.ctx, w.req.Resolved, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.result.GeneratedTextPath = generatedTextPath
|
||||||
|
w.metadata.GeneratedTextPath = generatedTextPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.GeneratedTextPath = generatedTextPath }); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("build render context", err)
|
||||||
|
}
|
||||||
|
renderContextPath, err := w.store.SaveRenderContext(w.ctx, w.req.Resolved, renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.result.RenderContextPath = renderContextPath
|
||||||
|
w.metadata.RenderContextPath = renderContextPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.RenderContextPath = renderContextPath }); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := w.handler.Render(renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("render template", err)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) finalizeReport(rendered []byte) (*ReportResult, error) {
|
||||||
|
reportPath, err := w.store.PrepareRenderedReport(w.ctx, w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
w.result.ReportPath = reportPath
|
||||||
|
w.metadata.RenderedReportPath = reportPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPath(func(paths *state.PromptExecutionPaths) { paths.RenderedReportPath = reportPath }); err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
finalized, err := finalizeRenderedReport(w.ctx, finalizeRenderedReportRequest{
|
||||||
|
Config: w.req.Config, Store: w.store, Resolved: w.req.Resolved, Metadata: w.metadata, MetadataPath: w.result.MetadataPath,
|
||||||
|
ExecutionArtifact: &w.executionArtifact, ManagedReportPath: reportPath, OutputPath: w.req.OutputPath,
|
||||||
|
Notifier: w.req.Notifier, noNotify: w.req.noNotify,
|
||||||
|
})
|
||||||
|
w.result.OutputPath, w.result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
|
||||||
|
w.result.Metadata, w.result.MetadataPath, w.result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistReachedPath(update func(*state.PromptExecutionPaths)) error {
|
||||||
|
return persistReachedPromptPath(w.ctx, w.store, w.req.Resolved, &w.executionArtifact, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistReachedPathAndMetadata(update func(*state.PromptExecutionPaths)) error {
|
||||||
|
if err := w.persistReachedPath(update); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) saveMetadata() error {
|
||||||
|
path, err := w.store.SaveMetadata(w.ctx, w.metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.result.MetadataPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) reportError(operation string, err error) error {
|
||||||
|
return generatedReportError(w.req.Resolved, w.metadata.RunID, operation, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistReachedPromptPath(
|
||||||
|
ctx context.Context,
|
||||||
|
store state.Store,
|
||||||
|
resolved report.Resolved,
|
||||||
|
artifact *state.PromptExecutionArtifact,
|
||||||
|
update func(*state.PromptExecutionPaths),
|
||||||
|
) error {
|
||||||
|
update(&artifact.Paths)
|
||||||
|
_, err := store.SavePromptExecution(ctx, resolved, *artifact)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedPromptExecutionArtifact(resolved report.Resolved, metadata state.Metadata, inspection PromptInspectionResult, err error) state.PromptExecutionArtifact {
|
||||||
|
now := time.Now()
|
||||||
|
return state.PromptExecutionArtifact{
|
||||||
|
SchemaVersion: state.PromptExecutionSchemaVersion, Status: state.PromptExecutionFailed,
|
||||||
|
ReportID: resolved.Definition.ID, RunID: metadata.RunID, PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion, StartedAt: now, EndedAt: now,
|
||||||
|
Error: state.NewPromptArtifactError(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifiedPromptError(operation string, err error) error {
|
||||||
|
if promptexec.CategoryOf(err) != "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.Generation, operation, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDebugWriteError(err error) error {
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](value T) *T { return &value }
|
||||||
142
internal/app/prompt_inspection.go
Normal file
142
internal/app/prompt_inspection.go
Normal 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)
|
||||||
|
}
|
||||||
191
internal/app/prompt_inspection_test.go
Normal file
191
internal/app/prompt_inspection_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(resolved.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
|
||||||
|
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecution(default) error = %v", err)
|
||||||
|
}
|
||||||
|
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
|
||||||
|
t.Fatalf("default result = %#v", defaultResult)
|
||||||
|
}
|
||||||
|
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
|
||||||
|
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecution(override) error = %v", err)
|
||||||
|
}
|
||||||
|
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
|
||||||
|
t.Fatalf("override result = %#v", overrideResult)
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
|
||||||
|
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
basePrompt := validPromptInspection(resolved.Definition)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prompt promptexec.PromptInspection
|
||||||
|
profile promptexec.ProfileInspection
|
||||||
|
lookupEnv func(string) (string, bool)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "extra input",
|
||||||
|
prompt: func() promptexec.PromptInspection {
|
||||||
|
value := basePrompt
|
||||||
|
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong schema",
|
||||||
|
prompt: func() promptexec.PromptInspection {
|
||||||
|
value := basePrompt
|
||||||
|
value.Output.SchemaPath = "unexpected.schema.json"
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "direct key",
|
||||||
|
prompt: basePrompt,
|
||||||
|
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
|
||||||
|
wantCategory: promptexec.MissingCredential,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing environment credential",
|
||||||
|
prompt: basePrompt,
|
||||||
|
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
|
||||||
|
lookupEnv: func(string) (string, bool) { return "", false },
|
||||||
|
wantCategory: promptexec.MissingCredential,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
|
||||||
|
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||||
|
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
|
||||||
|
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "resolved-secret-value") {
|
||||||
|
t.Fatalf("inspection error leaks provider value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||||
|
first := inspectionResolved(t)
|
||||||
|
second := first
|
||||||
|
second.Definition.ID = report.Today
|
||||||
|
second.Definition.PromptID = "weather.today"
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(first.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executor.prompts = map[string]promptexec.PromptInspection{
|
||||||
|
first.Definition.PromptID: validPromptInspection(first.Definition),
|
||||||
|
second.Definition.PromptID: validPromptInspection(second.Definition),
|
||||||
|
}
|
||||||
|
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecutions() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 || len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionPromptRequest struct {
|
||||||
|
id string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionExecutor struct {
|
||||||
|
prompt promptexec.PromptInspection
|
||||||
|
prompts map[string]promptexec.PromptInspection
|
||||||
|
profiles map[string]promptexec.ProfileInspection
|
||||||
|
promptErr error
|
||||||
|
promptRequests []inspectionPromptRequest
|
||||||
|
profileRequests []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
||||||
|
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
|
||||||
|
if e.promptErr != nil {
|
||||||
|
return promptexec.PromptInspection{}, e.promptErr
|
||||||
|
}
|
||||||
|
if prompt, ok := e.prompts[id]; ok {
|
||||||
|
return prompt, nil
|
||||||
|
}
|
||||||
|
return e.prompt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
e.profileRequests = append(e.profileRequests, id)
|
||||||
|
value, ok := e.profiles[id]
|
||||||
|
if !ok {
|
||||||
|
return promptexec.ProfileInspection{}, errors.New("profile missing")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
return nil, errors.New("unexpected execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionResolved(t *testing.T) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||||
|
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
||||||
|
Location: time.UTC,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||||
|
return promptexec.PromptInspection{
|
||||||
|
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
|
||||||
|
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
|
||||||
|
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||||
|
}
|
||||||
|
}
|
||||||
659
internal/app/single_report_workflow_test.go
Normal file
659
internal/app/single_report_workflow_test.go
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workflowCollector struct {
|
||||||
|
result *collect.Result
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *workflowCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
|
c.calls++
|
||||||
|
if c.err != nil {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
return c.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowExecutor struct {
|
||||||
|
definition report.Definition
|
||||||
|
raw []byte
|
||||||
|
inspectionErr error
|
||||||
|
profile promptexec.ProfileInspection
|
||||||
|
beforePreparationErr error
|
||||||
|
afterCallbackErr error
|
||||||
|
afterPreparationErr error
|
||||||
|
validation promptexec.ValidationStatus
|
||||||
|
executeCalls int
|
||||||
|
providerCalls int
|
||||||
|
request promptexec.ExecuteRequest
|
||||||
|
beforeProvider func()
|
||||||
|
preparationDebug *promptexec.PreparationDebug
|
||||||
|
executionDebug *promptexec.ExecutionDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||||
|
if e.inspectionErr != nil {
|
||||||
|
return promptexec.PromptInspection{}, e.inspectionErr
|
||||||
|
}
|
||||||
|
if id != e.definition.PromptID || version != e.definition.PromptVersion {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected prompt identity")
|
||||||
|
}
|
||||||
|
return validPromptInspection(e.definition), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
profile := e.profile
|
||||||
|
if profile.ProfileID == "" {
|
||||||
|
profile = promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
e.executeCalls++
|
||||||
|
e.request = req
|
||||||
|
if e.beforePreparationErr != nil {
|
||||||
|
return nil, e.beforePreparationErr
|
||||||
|
}
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
preparation := promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
|
||||||
|
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
|
||||||
|
}
|
||||||
|
if err := callback(preparation, e.preparationDebug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.afterCallbackErr != nil {
|
||||||
|
return nil, e.afterCallbackErr
|
||||||
|
}
|
||||||
|
if e.beforeProvider != nil {
|
||||||
|
e.beforeProvider()
|
||||||
|
}
|
||||||
|
e.providerCalls++
|
||||||
|
if e.afterPreparationErr != nil {
|
||||||
|
return nil, e.afterPreparationErr
|
||||||
|
}
|
||||||
|
validation := e.validation
|
||||||
|
if validation == "" {
|
||||||
|
validation = promptexec.ValidationPassed
|
||||||
|
}
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath, RawOutput: e.raw,
|
||||||
|
Debug: e.executionDebug,
|
||||||
|
Validation: promptexec.NewValidation(validation, "json_schema", e.definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowNotifier struct {
|
||||||
|
requests []NotificationRequest
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *workflowNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.requests = append(n.requests, req)
|
||||||
|
if n.err != nil {
|
||||||
|
return nil, n.err
|
||||||
|
}
|
||||||
|
return &NotificationResult{
|
||||||
|
RunID: "notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
id report.ID
|
||||||
|
date time.Time
|
||||||
|
raw string
|
||||||
|
wantOutput string
|
||||||
|
}{
|
||||||
|
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantOutput: "Showers are possible during the selected day."},
|
||||||
|
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantOutput: "Today starts with showers before improving."},
|
||||||
|
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantOutput: "Tomorrow starts with showers before improving."},
|
||||||
|
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON(), wantOutput: "Storm chances increase through late morning."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(test.id)
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
|
||||||
|
notifier := &workflowNotifier{}
|
||||||
|
outputPath := filepath.Join(t.TempDir(), test.name+".md")
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Metadata.ReportID != test.id || result.Metadata.PromptID != definition.PromptID {
|
||||||
|
t.Fatalf("metadata identity = %q/%q, want %q/%q", result.Metadata.ReportID, result.Metadata.PromptID, test.id, definition.PromptID)
|
||||||
|
}
|
||||||
|
if executor.request.PromptVersion != definition.PromptVersion {
|
||||||
|
t.Fatalf("prompt version = %q, want %q", executor.request.PromptVersion, definition.PromptVersion)
|
||||||
|
}
|
||||||
|
filesystem, storeErr := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if storeErr != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", storeErr)
|
||||||
|
}
|
||||||
|
preparation, loadErr := filesystem.LoadPromptPreparation(context.Background(), result.PreparationPath)
|
||||||
|
if loadErr != nil || preparation.PromptVersion != definition.PromptVersion {
|
||||||
|
t.Fatalf("persisted preparation prompt version = %q, error %v, want %q", preparation.PromptVersion, loadErr, definition.PromptVersion)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || executor.executeCalls != 1 || executor.providerCalls != 1 {
|
||||||
|
t.Fatalf("calls = collect %d execute %d provider %d, want one each", collector.calls, executor.executeCalls, executor.providerCalls)
|
||||||
|
}
|
||||||
|
persisted, readErr := os.ReadFile(result.DataPackagePath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read data package: %v", readErr)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(executor.request.DataPackage, persisted) {
|
||||||
|
t.Fatal("executor data package differs from exact persisted YAML bytes")
|
||||||
|
}
|
||||||
|
managed, readErr := os.ReadFile(result.ReportPath)
|
||||||
|
if readErr != nil || !strings.Contains(string(managed), test.wantOutput) {
|
||||||
|
t.Fatalf("managed report = %q, error %v, want generated template output %q", managed, readErr, test.wantOutput)
|
||||||
|
}
|
||||||
|
copied, readErr := os.ReadFile(outputPath)
|
||||||
|
if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath {
|
||||||
|
t.Fatalf("output copy mismatch/error/path = %v/%q", readErr, result.OutputPath)
|
||||||
|
}
|
||||||
|
if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath || notifier.requests[0].ReportPath == outputPath {
|
||||||
|
t.Fatalf("notification requests = %#v, want managed report source", notifier.requests)
|
||||||
|
}
|
||||||
|
wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup
|
||||||
|
if notifier.requests[0].PipelineID != wantPipeline {
|
||||||
|
t.Fatalf("pipeline = %q, want %q", notifier.requests[0].PipelineID, wantPipeline)
|
||||||
|
}
|
||||||
|
validDate := result.Metadata.ValidPeriod.Start.Format("2006-01-02")
|
||||||
|
wantBundlePaths := workflowBundlePaths(test.id, validDate, result.Metadata.RunID)
|
||||||
|
if strings.Join(notifier.requests[0].BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
||||||
|
t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths)
|
||||||
|
}
|
||||||
|
managedName := filepath.Base(result.ReportPath)
|
||||||
|
if !strings.HasPrefix(managedName, "report.") || !strings.Contains(managedName, "_"+test.name) || !strings.HasSuffix(managedName, ".md") || filepath.Base(result.OutputPath) != test.name+".md" {
|
||||||
|
t.Fatalf("output names = managed %q copy %q", result.ReportPath, result.OutputPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparationFailingStore struct {
|
||||||
|
state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderContextFailingStore struct {
|
||||||
|
state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s renderContextFailingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||||
|
path, err := s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for index := range snapshot.Outputs {
|
||||||
|
snapshot.Outputs[index].Value = "invalid module value"
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s preparationFailingStore) SavePromptPreparation(context.Context, report.Resolved, state.PromptPreparationArtifact) (string, error) {
|
||||||
|
return "", errors.New("injected preparation persistence failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedStopsAtConsequentialPromptFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*workflowExecutor)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
wantPreparation bool
|
||||||
|
wantExecution bool
|
||||||
|
wantRaw bool
|
||||||
|
wantProviderCall int
|
||||||
|
}{
|
||||||
|
{name: "preparation", configure: func(e *workflowExecutor) {
|
||||||
|
e.beforePreparationErr = promptexec.NewError(promptexec.Generation, "preparation failed", nil)
|
||||||
|
}, wantCategory: promptexec.Generation, wantPreparation: true},
|
||||||
|
{name: "credential disappears", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterCallbackErr = promptexec.NewError(promptexec.MissingCredential, "credential unavailable", nil)
|
||||||
|
}, wantCategory: promptexec.MissingCredential, wantPreparation: true, wantExecution: true},
|
||||||
|
{name: "capacity is not retried", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||||
|
}, wantCategory: promptexec.Capacity, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "canceled", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Canceled, "request canceled", context.Canceled)
|
||||||
|
}, wantCategory: promptexec.Canceled, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "deadline", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.DeadlineExceeded, "deadline exceeded", context.DeadlineExceeded)
|
||||||
|
}, wantCategory: promptexec.DeadlineExceeded, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "generation", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Generation, "generation failed", nil)
|
||||||
|
}, wantCategory: promptexec.Generation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "operational validation error", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.OperationalValidation, "validator failed", nil)
|
||||||
|
}, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "operational validation incomplete", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationSkipped }, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "schema rejection", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationFailed }, wantCategory: promptexec.ValidationRejected, wantPreparation: true, wantExecution: true, wantRaw: true, wantProviderCall: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
test.configure(executor)
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q, want partial result and %q", result, err, promptexec.CategoryOf(err), test.wantCategory)
|
||||||
|
}
|
||||||
|
if (result.PreparationPath != "") != test.wantPreparation || (result.ExecutionPath != "") != test.wantExecution || (result.GeneratedTextRawPath != "") != test.wantRaw {
|
||||||
|
t.Fatalf("paths = preparation %q execution %q raw %q", result.PreparationPath, result.ExecutionPath, result.GeneratedTextRawPath)
|
||||||
|
}
|
||||||
|
if executor.executeCalls != 1 || executor.providerCalls != test.wantProviderCall {
|
||||||
|
t.Fatalf("calls = execute %d provider %d, want 1/%d", executor.executeCalls, executor.providerCalls, test.wantProviderCall)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsInspectionAndCredentialsBeforeCollection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*workflowExecutor)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{name: "inspection", configure: func(e *workflowExecutor) { e.inspectionErr = errors.New("inspection unavailable") }, wantCategory: promptexec.InvalidConfiguration},
|
||||||
|
{name: "credential", configure: func(e *workflowExecutor) {
|
||||||
|
e.profile = promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true}
|
||||||
|
}, wantCategory: promptexec.MissingCredential},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition}
|
||||||
|
test.configure(executor)
|
||||||
|
collector := &workflowCollector{err: errors.New("collector must not run")}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 {
|
||||||
|
t.Fatalf("result/error/category/collect/execute = %#v/%v/%q/%d/%d", result, err, promptexec.CategoryOf(err), collector.calls, executor.executeCalls)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(cfg.Workspace.Root)
|
||||||
|
if readErr != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("workspace entries/error = %#v/%v, want no writes before collection", entries, readErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedStopsProviderWhenPreparationCannotPersist(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem},
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 {
|
||||||
|
t.Fatalf("result/error/preparation/provider = %#v/%v/%q/%d", result, err, result.PreparationPath, executor.providerCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
||||||
|
resolved, err := ResolveGenerate(request, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := filesystem.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
checked := false
|
||||||
|
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
executor.beforeProvider = func() {
|
||||||
|
checked = true
|
||||||
|
if _, statErr := os.Stat(paths.Preparation); statErr != nil {
|
||||||
|
t.Fatalf("preparation was not durable before provider execution: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
request.Executor = executor
|
||||||
|
request.Store = filesystem
|
||||||
|
result, err := GenerateDetailed(context.Background(), request)
|
||||||
|
if err != nil || result == nil || !checked || result.OutputPath != "" {
|
||||||
|
t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
configure func(*GenerateRequest, *workflowNotifier)
|
||||||
|
wantRaw bool
|
||||||
|
wantNormalized bool
|
||||||
|
wantContext bool
|
||||||
|
wantReport bool
|
||||||
|
wantOutput bool
|
||||||
|
wantNotify bool
|
||||||
|
}{
|
||||||
|
{name: "generated text decode", raw: `{`, wantRaw: true},
|
||||||
|
{name: "generated text domain", raw: `{}`, wantRaw: true},
|
||||||
|
{name: "render context build", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.Store = renderContextFailingStore{Store: req.Store}
|
||||||
|
}, wantRaw: true, wantNormalized: true},
|
||||||
|
{name: "render context persistence", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderContext}
|
||||||
|
}, wantRaw: true, wantNormalized: true},
|
||||||
|
{name: "template write", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
blocker := filepath.Join(t.TempDir(), "report-blocker")
|
||||||
|
if err := os.Mkdir(blocker, 0o700); err != nil {
|
||||||
|
t.Fatalf("create report blocker: %v", err)
|
||||||
|
}
|
||||||
|
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker}
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true},
|
||||||
|
{name: "output copy", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.OutputPath = t.TempDir()
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true},
|
||||||
|
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
|
||||||
|
notifier.err = errors.New("notification rejected")
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
|
||||||
|
notifier := &workflowNotifier{}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
req := GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
||||||
|
Executor: executor, Notifier: notifier, Store: filesystem,
|
||||||
|
}
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(&req, notifier)
|
||||||
|
}
|
||||||
|
result, err := GenerateDetailed(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("result/error = %#v/%v, want partial result and error", result, err)
|
||||||
|
}
|
||||||
|
if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized ||
|
||||||
|
(result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport ||
|
||||||
|
(result.OutputPath != "") != test.wantOutput || (result.NotificationPath != "") != test.wantNotify {
|
||||||
|
t.Fatalf("reached paths = raw %q normalized %q context %q report %q output %q notification %q", result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.OutputPath, result.NotificationPath)
|
||||||
|
}
|
||||||
|
if test.wantRaw {
|
||||||
|
persisted, readErr := os.ReadFile(result.GeneratedTextRawPath)
|
||||||
|
if readErr != nil || !bytes.Equal(persisted, []byte(test.raw)) {
|
||||||
|
t.Fatalf("retained raw output = %q, error %v", persisted, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath) {
|
||||||
|
t.Fatalf("notification requests = %#v", notifier.requests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedDebugFailuresRespectProviderBoundary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
createCollision func(string, report.Resolved) error
|
||||||
|
wantProviderCalls int
|
||||||
|
wantPreparationFile bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preparation debug",
|
||||||
|
createCollision: func(root string, resolved report.Resolved) error {
|
||||||
|
path := workflowDebugRunPath(root, resolved)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte("not a directory"), 0o600)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "execution debug", wantProviderCalls: 1, wantPreparationFile: true,
|
||||||
|
createCollision: func(root string, resolved report.Resolved) error {
|
||||||
|
return os.MkdirAll(filepath.Join(workflowDebugRunPath(root, resolved), "execution.json"), 0o700)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
||||||
|
resolved, err := ResolveGenerate(request, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "prompt-debug")
|
||||||
|
if err := test.createCollision(debugRoot, resolved); err != nil {
|
||||||
|
t.Fatalf("create debug collision: %v", err)
|
||||||
|
}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
request.Executor = executor
|
||||||
|
request.LLMDebugDir = debugRoot
|
||||||
|
result, err := GenerateDetailed(context.Background(), request)
|
||||||
|
if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q", result, err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if executor.providerCalls != test.wantProviderCalls || result.PreparationPath == "" || result.ExecutionPath != "" || result.GeneratedTextRawPath != "" {
|
||||||
|
t.Fatalf("provider/preparation/metadata/execution/raw = %d/%q/%q/%q/%q", executor.providerCalls, result.PreparationPath, result.MetadataPath, result.ExecutionPath, result.GeneratedTextRawPath)
|
||||||
|
}
|
||||||
|
if test.wantPreparationFile && result.MetadataPath == "" {
|
||||||
|
t.Fatal("execution debug failure lost previously persisted metadata")
|
||||||
|
}
|
||||||
|
preparationDebug := filepath.Join(workflowDebugRunPath(debugRoot, resolved), "preparation.json")
|
||||||
|
_, statErr := os.Stat(preparationDebug)
|
||||||
|
if (statErr == nil) != test.wantPreparationFile {
|
||||||
|
t.Fatalf("preparation debug stat error = %v, want file %t", statErr, test.wantPreparationFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowDebugRunPath(root string, resolved report.Resolved) string {
|
||||||
|
return filepath.Join(root, string(resolved.Definition.ID), resolved.ValidPeriod.Start.Format("2006-01-02"), resolved.Metadata().RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowBundlePaths(id report.ID, validDate, runID string) []string {
|
||||||
|
switch id {
|
||||||
|
case report.Daily:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md"}
|
||||||
|
case report.Today:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "today/index.md"}
|
||||||
|
case report.Tomorrow:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "tomorrow/index.md"}
|
||||||
|
case report.Hourly:
|
||||||
|
return []string{"hourly/index.md"}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedSelectsPriorSnapshotsForRetainedReports(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
id report.ID
|
||||||
|
date time.Time
|
||||||
|
raw string
|
||||||
|
wantPrior bool
|
||||||
|
wantRecentChanges bool
|
||||||
|
}{
|
||||||
|
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON()},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
definition := report.DefaultRegistry().MustLookup(test.id)
|
||||||
|
firstBundle := workflowBundle(t)
|
||||||
|
setWorkflowTemperatures(&firstBundle, 45)
|
||||||
|
first, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}},
|
||||||
|
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
secondBundle := workflowBundle(t)
|
||||||
|
setWorkflowTemperatures(&secondBundle, 85)
|
||||||
|
second, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}},
|
||||||
|
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if test.wantPrior && (second.PriorSnapshot == nil || second.PriorSnapshot.Metadata.RunID != first.Metadata.RunID || second.PriorSnapshot.Metadata.ReportID != test.id) {
|
||||||
|
t.Fatalf("prior snapshot = %#v, want first %s run %q", second.PriorSnapshot, test.id, first.Metadata.RunID)
|
||||||
|
}
|
||||||
|
if !test.wantPrior && second.PriorSnapshot != nil {
|
||||||
|
t.Fatalf("prior snapshot = %#v, want none for non-overlapping rolling window", second.PriorSnapshot)
|
||||||
|
}
|
||||||
|
if (len(second.RecentChanges) > 0) != test.wantRecentChanges {
|
||||||
|
t.Fatalf("recent changes = %#v, want present %t", second.RecentChanges, test.wantRecentChanges)
|
||||||
|
}
|
||||||
|
if (len(second.DataPackage.RecentChanges.Items) > 0) != test.wantRecentChanges {
|
||||||
|
t.Fatalf("data package recent changes = %#v, want present %t", second.DataPackage.RecentChanges.Items, test.wantRecentChanges)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorkflowTemperatures(bundle *weatherdata.Bundle, temperature float64) {
|
||||||
|
for index := range bundle.Hourly.Periods {
|
||||||
|
value := temperature
|
||||||
|
bundle.Hourly.Periods[index].TemperatureF = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowConfig(t *testing.T) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Location.ID = "home"
|
||||||
|
cfg.Location.Name = "Testville"
|
||||||
|
cfg.Location.Region = "MO"
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "reports.{report_id}.{artifact_group}"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowBundle(t *testing.T) weatherdata.Bundle {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bundle fixture: %v", err)
|
||||||
|
}
|
||||||
|
var bundle weatherdata.Bundle
|
||||||
|
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||||
|
t.Fatalf("decode bundle fixture: %v", err)
|
||||||
|
}
|
||||||
|
future := bundle.Hourly.Periods[0]
|
||||||
|
future.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
|
||||||
|
future.EndTime = workflowTime("2026-05-30T07:00:00-05:00")
|
||||||
|
bundle.Hourly.Periods = append(bundle.Hourly.Periods, future)
|
||||||
|
futureNarrative := bundle.Narrative.Periods[0]
|
||||||
|
futureNarrative.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
|
||||||
|
futureNarrative.EndTime = workflowTime("2026-05-30T18:00:00-05:00")
|
||||||
|
futureNarrative.Name = "Tomorrow"
|
||||||
|
bundle.Narrative.Periods = append(bundle.Narrative.Periods, futureNarrative)
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowTime(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHourlyWorkflowJSON() string {
|
||||||
|
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTomorrowWorkflowJSON() string {
|
||||||
|
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTodayWorkflowJSON() string {
|
||||||
|
return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDailyWorkflowJSON() string {
|
||||||
|
return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
}
|
||||||
21
internal/app/test_helpers_test.go
Normal file
21
internal/app/test_helpers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`) {
|
||||||
|
|||||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal 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"
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
@@ -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_")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
internal/cli/executor_factory.go
Normal file
59
internal/cli/executor_factory.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/cli/executor_factory_test.go
Normal file
78
internal/cli/executor_factory_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -17,26 +17,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type generateSummary struct {
|
type generateSummary struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
ReportName string `json:"reportName"`
|
ReportName string `json:"reportName"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
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"`
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
OutputPath string `json:"outputPath,omitempty"`
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
MetadataPath string `json:"metadataPath,omitempty"`
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
PreflightPath string `json:"preflightPath,omitempty"`
|
PreparationPath string `json:"preparationPath,omitempty"`
|
||||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
ExecutionPath string `json:"executionPath,omitempty"`
|
||||||
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
NotificationPath string `json:"notificationPath,omitempty"`
|
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||||
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type generateNotificationSummary struct {
|
type generateNotificationSummary struct {
|
||||||
@@ -85,9 +86,10 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
|||||||
summary.OutputPath = result.OutputPath
|
summary.OutputPath = result.OutputPath
|
||||||
summary.MetadataPath = result.MetadataPath
|
summary.MetadataPath = result.MetadataPath
|
||||||
summary.DataPackagePath = result.DataPackagePath
|
summary.DataPackagePath = result.DataPackagePath
|
||||||
summary.PreflightPath = result.PreflightPath
|
summary.PreparationPath = result.PreparationPath
|
||||||
|
summary.ExecutionPath = result.ExecutionPath
|
||||||
|
summary.LLMDebugPath = result.LLMDebugPath
|
||||||
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||||
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
|
||||||
summary.GeneratedTextPath = result.GeneratedTextPath
|
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||||
summary.RenderContextPath = result.RenderContextPath
|
summary.RenderContextPath = result.RenderContextPath
|
||||||
summary.NotificationPath = result.NotificationPath
|
summary.NotificationPath = result.NotificationPath
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
|||||||
startedAt := acceptedAt.Add(time.Minute)
|
startedAt := acceptedAt.Add(time.Minute)
|
||||||
finishedAt := startedAt.Add(time.Minute)
|
finishedAt := startedAt.Add(time.Minute)
|
||||||
result := &app.ReportResult{
|
result := &app.ReportResult{
|
||||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
PreflightPath: "/runs/hourly/preflight.json",
|
PreparationPath: "/runs/hourly/preparation.json",
|
||||||
ReportPath: "/runs/hourly/report.md",
|
ExecutionPath: "/runs/hourly/execution.json",
|
||||||
OutputPath: "/copies/hourly.md",
|
LLMDebugPath: "/operator-debug/hourly/2026-05-29/run-123",
|
||||||
MetadataPath: "/runs/hourly/metadata.json",
|
ReportPath: "/runs/hourly/report.md",
|
||||||
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
OutputPath: "/copies/hourly.md",
|
||||||
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||||
RenderContextPath: "/runs/hourly/render_context.json",
|
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||||
NotificationPath: "/runs/hourly/notification.json",
|
RenderContextPath: "/runs/hourly/render_context.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
Metadata: state.Metadata{
|
Metadata: state.Metadata{
|
||||||
ReportID: report.Hourly,
|
ReportID: report.Hourly,
|
||||||
PromptID: "weather.hourly_generated_text",
|
PromptID: "weather.hourly_generated_text",
|
||||||
@@ -58,7 +59,7 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
|||||||
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
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)
|
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||||
}
|
}
|
||||||
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.LLMDebugPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||||
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
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) {
|
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||||
@@ -71,20 +72,23 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
|||||||
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||||
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") {
|
||||||
|
t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
|
func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
|
||||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
result := &app.ReportResult{
|
result := &app.ReportResult{
|
||||||
DataPackagePath: "/runs/three-day/data_package.yaml",
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
PreflightPath: "/runs/three-day/preflight.json",
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
ReportPath: "/runs/three-day/report.md",
|
ReportPath: "/runs/daily/report.md",
|
||||||
OutputPath: "/copies/three-day.md",
|
OutputPath: "/copies/daily.md",
|
||||||
MetadataPath: "/runs/three-day/metadata.json",
|
MetadataPath: "/runs/daily/metadata.json",
|
||||||
Metadata: state.Metadata{
|
Metadata: state.Metadata{
|
||||||
ReportID: report.ThreeDay,
|
ReportID: report.Daily,
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.daily_generated_text",
|
||||||
RunID: "20260529T133000Z_three_day",
|
RunID: "20260529T133000Z_daily",
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
ValidPeriod: testSummaryPeriod(generatedAt),
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
},
|
},
|
||||||
@@ -92,8 +96,8 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
|
|||||||
|
|
||||||
summary := newGenerateSummary(result, nil)
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
|
if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" {
|
||||||
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
|
t.Fatalf("summary = %#v, want successful daily summary", summary)
|
||||||
}
|
}
|
||||||
if summary.Notification != nil || summary.NotificationPath != "" {
|
if summary.Notification != nil || summary.NotificationPath != "" {
|
||||||
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
||||||
@@ -102,18 +106,43 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal() error = %v", err)
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
}
|
}
|
||||||
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
|
for _, omitted := range []string{"notification"} {
|
||||||
if strings.Contains(string(data), omitted) {
|
if strings.Contains(string(data), omitted) {
|
||||||
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) {
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Daily,
|
||||||
|
RunID: "20260529T133000Z_daily",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(newGenerateSummary(result, errors.New("metadata write failed")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "generatedTextRawPath", "generatedTextPath", "renderContextPath", "notificationPath"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("partial summary includes unreached field %q:\n%s", omitted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
|
||||||
|
t.Fatalf("partial summary omits reached paths:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
func TestNewGenerateSummaryForNotificationFailure(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)
|
||||||
result := &app.ReportResult{
|
result := &app.ReportResult{
|
||||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
PreflightPath: "/runs/hourly/preflight.json",
|
PreparationPath: "/runs/hourly/preparation.json",
|
||||||
ReportPath: "/runs/hourly/report.md",
|
ReportPath: "/runs/hourly/report.md",
|
||||||
OutputPath: "/copies/hourly.md",
|
OutputPath: "/copies/hourly.md",
|
||||||
MetadataPath: "/runs/hourly/metadata.json",
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"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,15 +17,13 @@ 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 reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
@@ -34,16 +33,20 @@ Usage:
|
|||||||
|
|
||||||
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 an extra Markdown report copy where supported by the generate command.
|
||||||
|
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
|
||||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
--out-dir PATH Write extra Markdown report copies 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
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +61,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":
|
||||||
@@ -99,19 +113,18 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
}
|
}
|
||||||
|
|
||||||
type commonOptions struct {
|
type commonOptions struct {
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
Output string
|
Output string
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Quiet bool
|
LLMDebugDir string
|
||||||
|
Quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type generateOptions struct {
|
type generateOptions struct {
|
||||||
commonOptions
|
commonOptions
|
||||||
Date string
|
Date string
|
||||||
Start string
|
|
||||||
End string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
@@ -218,16 +231,22 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
req := app.GenerateRequest{
|
req := app.GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: reportKind,
|
Report: reportKind,
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
Now: r.Clock.Now(),
|
LLMDebugDir: opts.LLMDebugDir,
|
||||||
|
Now: r.Clock.Now(),
|
||||||
|
Executor: executor,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch reportKind {
|
switch reportKind {
|
||||||
@@ -248,19 +267,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 +300,11 @@ 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
|
||||||
|
}
|
||||||
|
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRun(args []string) (app.BatchRequest, error) {
|
func resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
@@ -310,10 +320,6 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
|||||||
if report == app.ReportDaily || report == app.ReportToday {
|
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
|
||||||
}
|
}
|
||||||
@@ -376,6 +382,7 @@ 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", "", "extra Markdown report copy path")
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
42
internal/cli/run_test.go
Normal file
42
internal/cli/run_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) {
|
||||||
|
opts, err := parseRunFlags([]string{"--llm-debug-dir", "/tmp/prompt-debug"})
|
||||||
|
if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" {
|
||||||
|
t.Fatalf("parseRunFlags() = %#v, %v", opts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRunActionConstructsOneExecutor(t *testing.T) {
|
||||||
|
for _, command := range []string{"morning", "evening"} {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
executor := &factoryExecutor{}
|
||||||
|
runner := Runner{
|
||||||
|
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||||
|
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||||
|
calls++
|
||||||
|
return executor, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, _, err := runner.resolveRunAction([]string{command, "--config", configPath, "--llm-debug-dir", "/tmp/debug"})
|
||||||
|
if err != nil || calls != 1 || req.Executor != executor || req.LLMDebugDir != "/tmp/debug" {
|
||||||
|
t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ type Config struct {
|
|||||||
Secrets SecretsConfig `yaml:"secrets"`
|
Secrets SecretsConfig `yaml:"secrets"`
|
||||||
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"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||||
@@ -81,12 +81,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"`
|
Profile string `yaml:"profile"`
|
||||||
ConfigPath string `yaml:"config_path"`
|
ProfileFile string `yaml:"profile_file"`
|
||||||
Profile string `yaml:"profile"`
|
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 PromptkitLocalConfig struct {
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
ConcurrencyLimit int `yaml:"concurrency_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ 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" {
|
if cfg.Workspace.Root != "workspace" {
|
||||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||||
@@ -158,6 +158,13 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsRetiredExecutionConfiguration(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, "scriptorium:\n binary: scriptorium\n"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "migrate to promptkit") {
|
||||||
|
t.Fatalf("LoadFile() error = %v, want actionable migration error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadReportModuleOverrides(t *testing.T) {
|
func TestLoadReportModuleOverrides(t *testing.T) {
|
||||||
path := writeConfig(t, `
|
path := writeConfig(t, `
|
||||||
reports:
|
reports:
|
||||||
@@ -275,39 +282,6 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadReportModuleOverrideAliases(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
reports:
|
|
||||||
three-day-outlook:
|
|
||||||
deterministic_modules:
|
|
||||||
- metadata
|
|
||||||
weekend_outlook:
|
|
||||||
deterministic_modules:
|
|
||||||
- metadata
|
|
||||||
storm_report:
|
|
||||||
deterministic_modules:
|
|
||||||
- metadata
|
|
||||||
`)
|
|
||||||
|
|
||||||
cfg, err := LoadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
overrides, err := cfg.ReportModuleOverrides()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
|
|
||||||
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
|
|
||||||
}
|
|
||||||
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
|
|
||||||
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
|
|
||||||
}
|
|
||||||
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
|
|
||||||
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadReportDistributorPathOverrides(t *testing.T) {
|
func TestLoadReportDistributorPathOverrides(t *testing.T) {
|
||||||
path := writeConfig(t, `
|
path := writeConfig(t, `
|
||||||
reports:
|
reports:
|
||||||
@@ -375,35 +349,6 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadReportDistributorPathOverrideAliases(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
reports:
|
|
||||||
three-day-outlook:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "three-day/{valid_start_date}/index.md"
|
|
||||||
weekend_outlook:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "weekend/{valid_start_date}/index.md"
|
|
||||||
`)
|
|
||||||
|
|
||||||
cfg, err := LoadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
overrides, err := cfg.ReportDistributorPathOverrides()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) {
|
|
||||||
t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay])
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) {
|
|
||||||
t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
|
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
|
||||||
cfg := Defaults()
|
cfg := Defaults()
|
||||||
rawOptions := map[string]any{
|
rawOptions := map[string]any{
|
||||||
@@ -683,21 +628,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 +684,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 +709,6 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
|
|
||||||
_, err := LoadFile(writeConfig(t, `
|
|
||||||
reports:
|
|
||||||
daily:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "daily/storm-{storm_id}.md"
|
|
||||||
storm:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "storm/{storm_id}/index.md"
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
|
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
reports:
|
reports:
|
||||||
@@ -869,29 +771,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 +1268,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 +1306,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") {
|
||||||
|
|||||||
@@ -43,9 +43,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{
|
Workspace: WorkspaceConfig{
|
||||||
Root: "workspace",
|
Root: "workspace",
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ 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 := rejectRetiredExecutionConfig(data); err != nil {
|
||||||
|
return fmt.Errorf("parse config %q: %w", path, err)
|
||||||
|
}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
return fmt.Errorf("parse config %q: %w", path, err)
|
return fmt.Errorf("parse config %q: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -70,3 +73,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
101
internal/config/promptkit_test.go
Normal file
101
internal/config/promptkit_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPromptkitDefaultsAndYAML(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
|
||||||
|
t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal([]byte(`
|
||||||
|
promptkit:
|
||||||
|
profile: selected
|
||||||
|
profile_file: /etc/weatherreporter/profile.yml
|
||||||
|
timeout: 45s
|
||||||
|
local:
|
||||||
|
endpoint: http://127.0.0.1:8080
|
||||||
|
concurrency_limit: 0
|
||||||
|
`), &cfg); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Promptkit.Profile != "selected" || cfg.Promptkit.ProfileFile != "/etc/weatherreporter/profile.yml" || cfg.Promptkit.Timeout != 45*time.Second || cfg.Promptkit.Local.Endpoint != "http://127.0.0.1:8080" || cfg.Promptkit.Local.ConcurrencyLimit != 0 {
|
||||||
|
t.Fatalf("Promptkit YAML = %#v", cfg.Promptkit)
|
||||||
|
}
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePromptkit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*PromptkitConfig)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "profile sources conflict",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.ProfileFile = "profile.yml"
|
||||||
|
cfg.ProfileDir = "profiles"
|
||||||
|
},
|
||||||
|
wantErr: "profile_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonpositive timeout",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.Timeout = 0
|
||||||
|
},
|
||||||
|
wantErr: "timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid local endpoint",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.Local.Endpoint = "not a URL"
|
||||||
|
},
|
||||||
|
wantErr: "local.endpoint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative local concurrency",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.Local.ConcurrencyLimit = -1
|
||||||
|
},
|
||||||
|
wantErr: "concurrency_limit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unlimited local concurrency",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.Local.Endpoint = "http://127.0.0.1:8080"
|
||||||
|
cfg.Local.ConcurrencyLimit = 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unregistered local backend",
|
||||||
|
mutate: func(cfg *PromptkitConfig) {
|
||||||
|
cfg.Local.Endpoint = ""
|
||||||
|
cfg.Local.ConcurrencyLimit = 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
test.mutate(&cfg.Promptkit)
|
||||||
|
err := Validate(cfg)
|
||||||
|
if test.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,14 +128,10 @@ func validateReportDistributorPathTemplates(reportKey string, reportID report.ID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sampleDistributorTemplateValues() DistributorTemplateValues {
|
func sampleDistributorTemplateValues() DistributorTemplateValues {
|
||||||
return sampleDistributorTemplateValuesForReport(report.Storm)
|
return sampleDistributorTemplateValuesForReport(report.Daily)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
|
func sampleDistributorTemplateValuesForReport(_ report.ID) DistributorTemplateValues {
|
||||||
stormID := ""
|
|
||||||
if reportID == report.Storm {
|
|
||||||
stormID = "2026-05-29T0000-2026-05-30T0000"
|
|
||||||
}
|
|
||||||
return DistributorTemplateValues{
|
return DistributorTemplateValues{
|
||||||
LocationID: "location",
|
LocationID: "location",
|
||||||
ReportID: "report",
|
ReportID: "report",
|
||||||
@@ -148,7 +144,6 @@ func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTem
|
|||||||
ValidEndTime: "0000",
|
ValidEndTime: "0000",
|
||||||
ValidStartStamp: "2026-05-29T0000",
|
ValidStartStamp: "2026-05-29T0000",
|
||||||
ValidEndStamp: "2026-05-30T0000",
|
ValidEndStamp: "2026-05-30T0000",
|
||||||
StormID: stormID,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ func Validate(cfg Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Scriptorium.Binary == "" {
|
if err := validatePromptkit(cfg.Promptkit); err != nil {
|
||||||
return fmt.Errorf("scriptorium.binary is required")
|
return err
|
||||||
}
|
|
||||||
if cfg.Scriptorium.Timeout <= 0 {
|
|
||||||
return fmt.Errorf("scriptorium.timeout must be greater than zero")
|
|
||||||
}
|
}
|
||||||
if cfg.Workspace.Root == "" {
|
if cfg.Workspace.Root == "" {
|
||||||
return fmt.Errorf("workspace.root is required")
|
return fmt.Errorf("workspace.root is required")
|
||||||
@@ -85,6 +82,25 @@ func Validate(cfg Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validatePromptkit(cfg PromptkitConfig) error {
|
||||||
|
if cfg.ProfileFile != "" && cfg.ProfileDir != "" {
|
||||||
|
return fmt.Errorf("promptkit.profile_file and promptkit.profile_dir cannot both be configured")
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
return fmt.Errorf("promptkit.timeout must be greater than zero")
|
||||||
|
}
|
||||||
|
if cfg.Local.Endpoint != "" {
|
||||||
|
parsed, err := url.Parse(cfg.Local.Endpoint)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("promptkit.local.endpoint must be an absolute URL when configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Local.ConcurrencyLimit < 0 {
|
||||||
|
return fmt.Errorf("promptkit.local.concurrency_limit must be zero or greater")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ type DerivedFacts struct {
|
|||||||
DailySummaries []forecast.DailySummary
|
DailySummaries []forecast.DailySummary
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
PrecipTiming forecast.PrecipTiming
|
PrecipTiming forecast.PrecipTiming
|
||||||
StormWindowSummary *forecast.DaypartSummary
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
|
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
|
||||||
@@ -121,16 +120,6 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
return DerivedFacts{}, err
|
return DerivedFacts{}, err
|
||||||
}
|
}
|
||||||
derived.DailySummaries = []forecast.DailySummary{*summary}
|
derived.DailySummaries = []forecast.DailySummary{*summary}
|
||||||
case report.ThreeDay, report.Weekend:
|
|
||||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
|
|
||||||
if err != nil {
|
|
||||||
return DerivedFacts{}, err
|
|
||||||
}
|
|
||||||
derived.DailySummaries = summaries
|
|
||||||
case report.Storm:
|
|
||||||
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
|
|
||||||
summary.AlertOverlaps = derived.AlertOverlaps
|
|
||||||
derived.StormWindowSummary = &summary
|
|
||||||
default:
|
default:
|
||||||
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
|
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
@@ -144,9 +133,6 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
|||||||
for _, summary := range derived.DailySummaries {
|
for _, summary := range derived.DailySummaries {
|
||||||
out = append(out, summary.Dayparts...)
|
out = append(out, summary.Dayparts...)
|
||||||
}
|
}
|
||||||
if derived.StormWindowSummary != nil {
|
|
||||||
out = append(out, *derived.StormWindowSummary)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,42 +100,9 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
|
func TestBuildDerivedTomorrow(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
|
for _, id := range []report.ID{report.Tomorrow} {
|
||||||
bundle := testBundle(location)
|
|
||||||
bundle.Narrative = nil
|
|
||||||
bundle.Alerts = nil
|
|
||||||
bundle.Discussion = nil
|
|
||||||
bundle.WeatherStory = nil
|
|
||||||
|
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
|
||||||
Resolved: resolved,
|
|
||||||
Timezone: location.String(),
|
|
||||||
Dayparts: testDayparts(),
|
|
||||||
Collected: BuildCollected(bundle),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDerived() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(derived.DailySummaries) != 3 {
|
|
||||||
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
|
|
||||||
}
|
|
||||||
if len(derived.ValidPeriodNarrativePeriods) != 0 {
|
|
||||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
|
|
||||||
}
|
|
||||||
if len(derived.AlertOverlaps) != 0 {
|
|
||||||
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
|
|
||||||
}
|
|
||||||
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
|
||||||
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
|
||||||
location := testLocation()
|
|
||||||
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
|
|
||||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -183,8 +150,8 @@ func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
|
|||||||
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
||||||
}
|
}
|
||||||
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
|
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 {
|
||||||
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
|
t.Fatalf("hourly summaries daily=%#v daypart=%#v, want none", derived.DailySummaries, derived.DaypartSummaries)
|
||||||
}
|
}
|
||||||
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||||
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
||||||
@@ -209,36 +176,6 @@ func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
|
||||||
location := testLocation()
|
|
||||||
resolved := resolveStormForTest(t, location)
|
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
|
||||||
Resolved: resolved,
|
|
||||||
Timezone: location.String(),
|
|
||||||
Dayparts: testDayparts(),
|
|
||||||
Collected: BuildCollected(testBundle(location)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDerived() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(derived.ValidPeriodHourlyPeriods) != 2 {
|
|
||||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
|
|
||||||
}
|
|
||||||
if len(derived.ValidPeriodDailyPeriods) != 1 {
|
|
||||||
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
|
|
||||||
}
|
|
||||||
if derived.StormWindowSummary == nil {
|
|
||||||
t.Fatal("StormWindowSummary = nil, want summary")
|
|
||||||
}
|
|
||||||
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
|
|
||||||
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
|
|
||||||
}
|
|
||||||
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
|
|
||||||
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||||
@@ -263,24 +200,6 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
|||||||
wantOutlookIDs: []string{"sat-enhanced"},
|
wantOutlookIDs: []string{"sat-enhanced"},
|
||||||
wantDiscussion: []string{"day2"},
|
wantDiscussion: []string{"day2"},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "three day",
|
|
||||||
resolved: resolveForTest(t, report.ThreeDay, now, location),
|
|
||||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
|
|
||||||
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "weekend",
|
|
||||||
resolved: resolveForTest(t, report.Weekend, now, location),
|
|
||||||
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
|
|
||||||
wantDiscussion: []string{"day2", "day3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "storm",
|
|
||||||
resolved: resolveStormForTest(t, location),
|
|
||||||
wantOutlookIDs: []string{"fri-storm", "fri-low"},
|
|
||||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -476,20 +395,6 @@ func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Lo
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
|
|
||||||
t.Helper()
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLocation() *time.Location {
|
func testLocation() *time.Location {
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -217,62 +217,6 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
|
||||||
if !period.IsValid() {
|
|
||||||
return nil, fmt.Errorf("valid forecast period is required")
|
|
||||||
}
|
|
||||||
if location == nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
var summaries []DailySummary
|
|
||||||
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
|
|
||||||
overlap, ok := day.Intersection(period)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summaries = append(summaries, *summary)
|
|
||||||
}
|
|
||||||
return summaries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
|
||||||
if bundle == nil {
|
|
||||||
return nil, fmt.Errorf("forecast bundle is required")
|
|
||||||
}
|
|
||||||
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
|
|
||||||
return nil, fmt.Errorf("hourly forecast data is required")
|
|
||||||
}
|
|
||||||
windows, err := ResolveDayparts(period.Start, location, dayparts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
alerts := AlertOverlaps(bundle.Alerts, period)
|
|
||||||
summary := &DailySummary{
|
|
||||||
Date: period.Start.In(location).Format(timeutil.DateLayout),
|
|
||||||
Period: period,
|
|
||||||
NarrativePeriods: SelectNarrativePeriods(bundle, period),
|
|
||||||
AlertOverlaps: alerts,
|
|
||||||
Discussion: SelectDiscussion(bundle),
|
|
||||||
SourceWarnings: bundle.Warnings,
|
|
||||||
SourceProvenance: bundle.Sources,
|
|
||||||
}
|
|
||||||
for _, window := range windows {
|
|
||||||
clipped, ok := window.Period.Intersection(period)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
|
|
||||||
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
|
|
||||||
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
|
|
||||||
summary.Dayparts = append(summary.Dayparts, daypartSummary)
|
|
||||||
}
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
|
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -154,41 +154,6 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
|
||||||
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
||||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
|
||||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
|
||||||
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
|
||||||
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
|
|
||||||
}}}
|
|
||||||
period := timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
|
|
||||||
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
|
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(summaries) != 3 {
|
|
||||||
t.Fatalf("summaries length = %d, want 3", len(summaries))
|
|
||||||
}
|
|
||||||
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
|
||||||
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
|
|
||||||
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
|
|
||||||
}
|
|
||||||
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
|
|
||||||
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertOverlap(t *testing.T) {
|
func TestAlertOverlap(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||||
)
|
)
|
||||||
@@ -69,9 +70,6 @@ var catalog = []catalogEntry{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LookupDefinition(definition report.Definition) (Handler, error) {
|
func LookupDefinition(definition report.Definition) (Handler, error) {
|
||||||
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
|
||||||
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemaKnown, templateKnown bool
|
var schemaKnown, templateKnown bool
|
||||||
for _, entry := range catalog {
|
for _, entry := range catalog {
|
||||||
@@ -109,7 +107,7 @@ func (h Handler) TemplateID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) Schema() ([]byte, error) {
|
func (h Handler) Schema() ([]byte, error) {
|
||||||
data, err := reporttemplate.Schema(h.schemaID)
|
data, err := promptassets.Schema(h.schemaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
|
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import (
|
|||||||
|
|
||||||
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
|
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
|
||||||
for _, definition := range report.DefaultRegistry().All() {
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t.Run(string(definition.ID), func(t *testing.T) {
|
t.Run(string(definition.ID), func(t *testing.T) {
|
||||||
handler, err := LookupDefinition(definition)
|
handler, err := LookupDefinition(definition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +93,6 @@ func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
|
|||||||
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
||||||
definition := report.Definition{
|
definition := report.Definition{
|
||||||
ID: report.Today,
|
ID: report.Today,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "today",
|
GeneratedTextSchemaID: "today",
|
||||||
TemplateID: "today",
|
TemplateID: "today",
|
||||||
}
|
}
|
||||||
@@ -122,7 +118,6 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
|||||||
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
|
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
|
||||||
definition := report.Definition{
|
definition := report.Definition{
|
||||||
ID: report.Daily,
|
ID: report.Daily,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
}
|
}
|
||||||
@@ -184,7 +179,6 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
|
|
||||||
todayHandler, err := LookupDefinition(report.Definition{
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
ID: report.Today,
|
ID: report.Today,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "today",
|
GeneratedTextSchemaID: "today",
|
||||||
TemplateID: "today",
|
TemplateID: "today",
|
||||||
})
|
})
|
||||||
@@ -207,7 +201,6 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
|
|
||||||
dailyHandler, err := LookupDefinition(report.Definition{
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
ID: report.Daily,
|
ID: report.Daily,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
})
|
})
|
||||||
@@ -262,7 +255,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|||||||
|
|
||||||
todayHandler, err := LookupDefinition(report.Definition{
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
ID: report.Today,
|
ID: report.Today,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "today",
|
GeneratedTextSchemaID: "today",
|
||||||
TemplateID: "today",
|
TemplateID: "today",
|
||||||
})
|
})
|
||||||
@@ -282,7 +274,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|||||||
|
|
||||||
dailyHandler, err := LookupDefinition(report.Definition{
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
ID: report.Daily,
|
ID: report.Daily,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
})
|
})
|
||||||
@@ -304,7 +295,6 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|||||||
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
|
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
|
||||||
handler, err := LookupDefinition(report.Definition{
|
handler, err := LookupDefinition(report.Definition{
|
||||||
ID: report.Daily,
|
ID: report.Daily,
|
||||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
Your task is to generate a local weather forecast analysis from the following YAML data package, which is prepared by the weatherreporter application.
|
||||||
|
|
||||||
|
Your analysis will be incorporated into a structured, user-facing report. The report may be for today, tomorrow, or a future date. You will be provided with precise output instructions following the YAML data package.
|
||||||
|
|
||||||
|
# SOURCE ROLES AND WEIGHTING
|
||||||
|
|
||||||
|
Use `report` and `briefing.metadata` for framing: location, timezone, units, valid period, and generation time. Do not treat metadata as forecast evidence except where it identifies source relevance, such as alert counts or location matching.
|
||||||
|
|
||||||
|
For weather interpretation, think in four source layers, in this order:
|
||||||
|
|
||||||
|
## 1. Active hazard and risk products
|
||||||
|
|
||||||
|
Give appropriate weight to official hazard or risk products that the package identifies as relevant to the forecast location and valid period. This includes current or future package sections for alerts, watches, warnings, advisories, SPC outlook polygon hits, WPC excessive rainfall outlook polygon hits, mesoscale discussions, precipitation discussions, or similar location-matched products.
|
||||||
|
|
||||||
|
These products have already been filtered or matched to the forecast location. Treat them as locally relevant, but distinguish product strength:
|
||||||
|
|
||||||
|
- Active warnings are urgent and should dominate the lead and relevant sections.
|
||||||
|
- Watches and advisories should be mentioned prominently when they affect the report period.
|
||||||
|
- Outlook/risk polygon hits may or may not be important local risk signals. Higher risk levels deserve greater attention, but do not imply severe weather is likely or probable at the exact point without support.
|
||||||
|
- Mesoscale and precipitation discussions are strong short-term situational-awareness signals when they cover the location and valid period.
|
||||||
|
|
||||||
|
For the current schema, use `briefing.applicable_risk_products.alert_digest` and `briefing.metadata.alerts` to determine whether relevant local alerts exist. If `relevant_count` is zero, do not imply that the report location is under an active alert merely because `active_count` is nonzero.
|
||||||
|
|
||||||
|
## 2. Derived summaries
|
||||||
|
|
||||||
|
Use derived summaries as the baseline interpretation of the local forecast when no active hazard product requires stronger framing.
|
||||||
|
|
||||||
|
- Use `briefing.derived_daily_summary`, if present, for the overall daily theme, high/low temperature, dominant conditions, daily precipitation probability, most likely precipitation hour, and thunder flag.
|
||||||
|
- Use `briefing.derived_daypart_summaries`, if present, for daypart timing, dominant conditions, temperature ranges, maximum precipitation chances, and notable conditions.
|
||||||
|
- Use `briefing.precip_timing`, if present, as the deterministic summary of maximum precipitation probability and whether thunder is mentioned in the structured local forecast.
|
||||||
|
- Use `briefing.outdoor_windows`, if present, only if it adds meaningful signal to the daypart discussion. Do not turn the report into outdoor-planning advice.
|
||||||
|
|
||||||
|
## 3. Narrative products
|
||||||
|
|
||||||
|
Use `briefing.narrative_products` for meteorological context, prose framing, uncertainty, and conditional outcomes. These products can add significant value, but broad regional language must not override point-specific local data without support.
|
||||||
|
|
||||||
|
- Use `briefing.narrative_products.narrative_forecast.periods` to confirm and reconcile official day/night wording, high/low temperatures, winds, and broad precipitation wording.
|
||||||
|
- Use `briefing.narrative_products.weather_story` and `briefing.narrative_products.area_forecast_discussion.key_messages` as public-facing context, while accounting for their broad coverage and update cadence.
|
||||||
|
- Use `briefing.narrative_products.area_forecast_discussion.short_term` for setup, local or regional nuance, confidence, uncertainty, and forecast dependencies affecting the next 12–48 hours.
|
||||||
|
- Use `briefing.narrative_products.area_forecast_discussion.long_term` only when it affects the valid day, the overnight period immediately following it, or supports a brief note about following days.
|
||||||
|
- Use `briefing.narrative_products.spc_convective_discussion.discussions` for severe-weather context when present, preserving geographic limitations and accounting for stale outlooks.
|
||||||
|
|
||||||
|
## 4. Raw underlying data
|
||||||
|
|
||||||
|
Use `briefing.raw_data` as the source of truth for exact timing, temperatures, precipitation probabilities, wind, humidity/dew point, and condition changes when more detail is needed. `briefing.raw_data.hourly_forecast.periods` is the most granular local forecast source. Use `briefing.raw_data.current_conditions` only as generation-time context.
|
||||||
|
|
||||||
|
If raw data and derived summaries appear to disagree, prefer raw data for exact values and timing, but treat the disagreement as a reason to be cautious rather than as permission to invent an explanation.
|
||||||
|
|
||||||
|
# CONFLICT RESOLUTION
|
||||||
|
|
||||||
|
When sources differ, ask:
|
||||||
|
|
||||||
|
1. Which source is most local to the forecast point?
|
||||||
|
2. Which source is valid for the report period or near-term window?
|
||||||
|
3. Which source is most authoritative for the type of claim being made?
|
||||||
|
4. Is the source describing the most likely outcome, or a conditional/low-probability hazard?
|
||||||
|
|
||||||
|
Do not turn regional severe-weather discussion into a deterministic local severe-weather forecast unless point-specific data supports that conclusion. Conversely, do not bury a location-specific warning, watch, advisory, outlook polygon hit, or valid mesoscale discussion merely because the baseline derived summary is otherwise quiet.
|
||||||
|
|
||||||
|
# HAZARD AND PRECIPITATION RULES
|
||||||
|
|
||||||
|
Mention a hazard only to the extent supported by location-specific products, local structured forecast data, or clearly applicable narrative text. Preserve product strength, uncertainty, geography, and timing. Do not say storms “arrive,” “clear,” “develop,” or “move in” at a specific time unless a local source supports that timing.
|
||||||
|
|
||||||
|
Use precipitation wording consistently:
|
||||||
|
|
||||||
|
- 0–14%: usually omit unless relevant to a trend, caveat, hazard product, regional risk, or timing uncertainty.
|
||||||
|
- 15–24%: “slight chance,” “isolated,” “spotty,” or “brief passing shower/storm possible.”
|
||||||
|
- 25–39%: “chance,” “scattered,” or “some showers/storms possible.”
|
||||||
|
- 40–59%: “good chance” or “showers/storms likely enough to plan around.”
|
||||||
|
- 60%+: “likely,” “wet,” or “unsettled,” if consistent with the narrative forecast.
|
||||||
|
|
||||||
|
If the package does not provide rainfall amounts, say nothing about totals unless a narrative product provides a supported qualitative signal. Do not invent QPF. If local precipitation chances are low and no meaningful local impacts are expected, do not imply thunderstorms are likely solely because regional precipitation or severe weather appears in narrative text.
|
||||||
|
|
||||||
|
# STYLE RULES
|
||||||
|
|
||||||
|
- Plainspoken, precise, and weather-literate.
|
||||||
|
- Compact, but not shallow.
|
||||||
|
- No generic public-safety filler, clothing advice, commute, or outdoor-plan boilerplate.
|
||||||
|
- No unsupported precision or apologies for missing data.
|
||||||
|
- Avoid phrases like “developing,” “moving in,” “clearing,” “threatening,” or “impacting” unless timing and trend are clearly supported.
|
||||||
|
- Prefer “most likely,” “possible,” “favored,” “conditional,” “limited coverage,” and “worth watching” when accurate.
|
||||||
9
internal/promptassets/assets/prompts/common/system.md
Normal file
9
internal/promptassets/assets/prompts/common/system.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
You are WeatherReporter, a concise personal weather briefing writer.
|
||||||
|
|
||||||
|
You generate local weather forecast analysis from structured data packages prepared by the weatherreporter application.
|
||||||
|
|
||||||
|
Use only the provided data package as your source of truth. Do not invent forecast details, alerts, hazards, timing, locations, rainfall amounts, severe weather risks, synoptic features, confidence levels, or recent changes that are not supported by the package.
|
||||||
|
|
||||||
|
The reader is intelligent and weather-literate, but not a professional meteorologist. If asked to provide narrative analysis or commentary, write in plain, precise, meteorologically informed language. Avoid hype, filler, generic safety advice, and TV-weather style. Provide polished prose that avoids highly technical meteorological jargon or shorthand.
|
||||||
|
|
||||||
|
Do not mention that you are an AI model.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
TASK: You are writing structured prose slots for a daily weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data. The report focuses on the upcoming civil day in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. One or two sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences. If an active warning is relevant during the report period, lead with the hazard. Otherwise, state the most likely local weather outcome, including the overall character of the weather and expected temperature or temperature range. The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome when one exists.
|
||||||
|
|
||||||
|
Distinguish the main weather outcome from its caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as one risk throughout the period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
# Forecast discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful. Useful context may include synoptic pattern, fronts or boundaries, shortwaves, troughs or ridges, instability, moisture, shear, forcing, capping, regional placement of precipitation or severe-weather chances, hazards, timing windows, confidence, uncertainty, conditional outcomes, and relevant notes about following days.
|
||||||
|
|
||||||
|
In most cases, include three paragraphs: a two-to-four sentence relevant local or regional setup; a two-to-four sentence main uncertainty or conditional factor when present; and a two-to-four sentence next-day or broader-pattern note when supported.
|
||||||
|
|
||||||
|
# Precipitation timing
|
||||||
|
|
||||||
|
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration.
|
||||||
|
|
||||||
|
# Narrative source selection
|
||||||
|
|
||||||
|
Use `briefing.derived_daily_summary`, `briefing.derived_daypart_summaries`, `briefing.narrative_products.narrative_forecast.periods`, and `briefing.raw_data.hourly_forecast.periods` as primary sources. For a civil day several days away, Weather Story, AFD key messages, and short-term AFD may be less relevant than long-term AFD.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
id: weather.daily_generated_text
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: gemini-flash-latest
|
||||||
|
description: Daily weather report analysis prompt.
|
||||||
|
inputs:
|
||||||
|
- name: data_package
|
||||||
|
required: true
|
||||||
|
content_type: application/yaml
|
||||||
|
description: Structured weather data package
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ../common/system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ../common/data_package.user.md
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
{{input "data_package"}}
|
||||||
|
- role: user
|
||||||
|
content_file: ./daily_generated_text.user.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: daily.generated_text.schema.json
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 next several hours in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. One or two sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. Two or three 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.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences. If an active warning is relevant during the report period, lead with the hazard. Otherwise, state the most likely local weather outcome, including its overall character and expected temperature or temperature range. If conditions shift over time, identify the hour when the shift is most likely to occur; if they are stable, use one descriptor that best captures the period.
|
||||||
|
|
||||||
|
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome when one exists. Distinguish the main weather outcome from its caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as one risk throughout the period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
# Forecast discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful. Useful context may include synoptic pattern, fronts or boundaries, shortwaves, troughs or ridges, instability, moisture, shear, forcing, capping, regional placement of precipitation or severe-weather chances, hazards, timing windows, confidence, uncertainty, conditional outcomes, and relevant notes about following days.
|
||||||
|
|
||||||
|
# Precipitation timing
|
||||||
|
|
||||||
|
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
id: weather.hourly_generated_text
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: gemini-flash-latest
|
||||||
|
description: Hourly weather report analysis prompt.
|
||||||
|
inputs:
|
||||||
|
- name: data_package
|
||||||
|
required: true
|
||||||
|
content_type: application/yaml
|
||||||
|
description: Structured weather data package
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ../common/system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ../common/data_package.user.md
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
{{input "data_package"}}
|
||||||
|
- role: user
|
||||||
|
content_file: ./hourly_generated_text.user.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: hourly.generated_text.schema.json
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 current civil day in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. One or two sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences. If an active warning is relevant during the report period, lead with the hazard. Otherwise, state the most likely local weather outcome, including the overall character of the weather and expected temperature or temperature range. The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome when one exists.
|
||||||
|
|
||||||
|
Distinguish the main weather outcome from its caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as one risk throughout the period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
# Forecast discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful. Useful context may include synoptic pattern, fronts or boundaries, shortwaves, troughs or ridges, instability, moisture, shear, forcing, capping, regional placement of precipitation or severe-weather chances, hazards, timing windows, confidence, uncertainty, conditional outcomes, and relevant notes about following days.
|
||||||
|
|
||||||
|
In most cases, include three paragraphs: a two-to-four sentence relevant local or regional setup; a two-to-four sentence main uncertainty or conditional factor when present; and a two-to-four sentence next-day or broader-pattern note when supported.
|
||||||
|
|
||||||
|
# Precipitation timing
|
||||||
|
|
||||||
|
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
id: weather.today_generated_text
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: gemini-flash-latest
|
||||||
|
description: Today's weather report analysis prompt.
|
||||||
|
inputs:
|
||||||
|
- name: data_package
|
||||||
|
required: true
|
||||||
|
content_type: application/yaml
|
||||||
|
description: Structured weather data package
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ../common/system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ../common/data_package.user.md
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
{{input "data_package"}}
|
||||||
|
- role: user
|
||||||
|
content_file: ./today_generated_text.user.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: today.generated_text.schema.json
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 next civil day in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. One or two sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences. If an active warning is relevant during the report period, lead with the hazard. Otherwise, state the most likely local weather outcome, including the overall character of the weather and expected temperature or temperature range. The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome when one exists.
|
||||||
|
|
||||||
|
Distinguish the main weather outcome from its caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as one risk throughout the period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
# Forecast discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful. Useful context may include synoptic pattern, fronts or boundaries, shortwaves, troughs or ridges, instability, moisture, shear, forcing, capping, regional placement of precipitation or severe-weather chances, hazards, timing windows, confidence, uncertainty, conditional outcomes, and relevant notes about following days.
|
||||||
|
|
||||||
|
In most cases, include three paragraphs: a two-to-four sentence relevant local or regional setup; a two-to-four sentence main uncertainty or conditional factor when present; and a two-to-four sentence next-day or broader-pattern note when supported.
|
||||||
|
|
||||||
|
# Precipitation timing
|
||||||
|
|
||||||
|
Use one or two sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
id: weather.tomorrow_generated_text
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: gemini-flash-latest
|
||||||
|
description: Tomorrow's weather report analysis prompt.
|
||||||
|
inputs:
|
||||||
|
- name: data_package
|
||||||
|
required: true
|
||||||
|
content_type: application/yaml
|
||||||
|
description: Structured weather data package
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ../common/system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ../common/data_package.user.md
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
{{input "data_package"}}
|
||||||
|
- role: user
|
||||||
|
content_file: ./tomorrow_generated_text.user.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: tomorrow.generated_text.schema.json
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.daily.generated_text.schema.json",
|
||||||
|
"title": "Daily 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"},
|
||||||
|
"confidence": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.hourly.generated_text.schema.json",
|
||||||
|
"title": "Hourly GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["summary", "forecast_discussion"],
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string"},
|
||||||
|
"forecast_discussion": {"type": "string"},
|
||||||
|
"precipitation_timing": {"type": "string"},
|
||||||
|
"confidence": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$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"},
|
||||||
|
"confidence": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$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"},
|
||||||
|
"confidence": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/promptassets/promptassets.go
Normal file
49
internal/promptassets/promptassets.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Package promptassets owns the embedded Promptkit prompt and schema corpus.
|
||||||
|
package promptassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/prompts assets/schemas
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
var schemaPaths = map[string]string{
|
||||||
|
"daily": "assets/schemas/daily.generated_text.schema.json",
|
||||||
|
"hourly": "assets/schemas/hourly.generated_text.schema.json",
|
||||||
|
"today": "assets/schemas/today.generated_text.schema.json",
|
||||||
|
"tomorrow": "assets/schemas/tomorrow.generated_text.schema.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptFS returns the embedded prompt definitions and their referenced files.
|
||||||
|
func PromptFS() fs.FS {
|
||||||
|
fsys, err := fs.Sub(assets, "assets/prompts")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("embedded prompt assets: %v", err))
|
||||||
|
}
|
||||||
|
return fsys
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaFS returns the embedded generated-text JSON schemas.
|
||||||
|
func SchemaFS() fs.FS {
|
||||||
|
fsys, err := fs.Sub(assets, "assets/schemas")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("embedded schema assets: %v", err))
|
||||||
|
}
|
||||||
|
return fsys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema returns an independent copy of the canonical schema for id.
|
||||||
|
func Schema(id string) ([]byte, error) {
|
||||||
|
path, ok := schemaPaths[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown generated text schema %q", id)
|
||||||
|
}
|
||||||
|
data, err := assets.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return append([]byte(nil), data...), nil
|
||||||
|
}
|
||||||
161
internal/promptassets/promptassets_test.go
Normal file
161
internal/promptassets/promptassets_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package promptassets_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promptDefinition struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
|
Inputs []struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType string `yaml:"content_type"`
|
||||||
|
} `yaml:"inputs"`
|
||||||
|
Output struct {
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
ValidationMode string `yaml:"validation_mode"`
|
||||||
|
SchemaPath string `yaml:"schema_path"`
|
||||||
|
RepairAttempts *int `yaml:"repair_attempts"`
|
||||||
|
} `yaml:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptAssetsDeclareTheFourGeneratedTextPrompts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
id string
|
||||||
|
schemaID string
|
||||||
|
}{
|
||||||
|
{"daily/daily_generated_text.yml", "weather.daily_generated_text", "daily"},
|
||||||
|
{"today/today_generated_text.yml", "weather.today_generated_text", "today"},
|
||||||
|
{"tomorrow/tomorrow_generated_text.yml", "weather.tomorrow_generated_text", "tomorrow"},
|
||||||
|
{"hourly/hourly_generated_text.yml", "weather.hourly_generated_text", "hourly"},
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions := 0
|
||||||
|
if err := fs.WalkDir(promptassets.PromptFS(), ".", func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(path, ".yml") {
|
||||||
|
definitions++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("walk embedded prompts: %v", err)
|
||||||
|
}
|
||||||
|
if definitions != len(tests) {
|
||||||
|
t.Fatalf("prompt definitions = %d, want %d", definitions, len(tests))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.id, func(t *testing.T) {
|
||||||
|
data, err := fs.ReadFile(promptassets.PromptFS(), tc.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read prompt definition: %v", err)
|
||||||
|
}
|
||||||
|
var definition promptDefinition
|
||||||
|
if err := yaml.Unmarshal(data, &definition); err != nil {
|
||||||
|
t.Fatalf("decode prompt definition: %v", err)
|
||||||
|
}
|
||||||
|
if definition.ID != tc.id || definition.Version != "1.0.0" || definition.DefaultProfile != "gemini-flash-latest" {
|
||||||
|
t.Fatalf("definition = %#v, want %s version 1.0.0 and gemini-flash-latest", definition, tc.id)
|
||||||
|
}
|
||||||
|
if len(definition.Inputs) != 1 || definition.Inputs[0].Name != "data_package" || !definition.Inputs[0].Required || definition.Inputs[0].ContentType != "application/yaml" {
|
||||||
|
t.Fatalf("inputs = %#v, want one required YAML data_package", definition.Inputs)
|
||||||
|
}
|
||||||
|
if definition.Output.Format != "json" || definition.Output.ValidationMode != "json_schema" || definition.Output.SchemaPath != tc.schemaID+".generated_text.schema.json" || definition.Output.RepairAttempts != nil {
|
||||||
|
t.Fatalf("output = %#v, want JSON schema output without repair attempts", definition.Output)
|
||||||
|
}
|
||||||
|
if _, err := promptassets.Schema(tc.schemaID); err != nil {
|
||||||
|
t.Fatalf("Schema(%q) error = %v", tc.schemaID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemasAreCanonicalAndIndependent(t *testing.T) {
|
||||||
|
for _, id := range []string{"daily", "today", "tomorrow", "hourly"} {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
data, err := promptassets.Schema(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
}
|
||||||
|
var schema struct {
|
||||||
|
ID string `json:"$id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AdditionalProperties bool `json:"additionalProperties"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
Properties map[string]any `json:"properties"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &schema); err != nil {
|
||||||
|
t.Fatalf("decode schema: %v", err)
|
||||||
|
}
|
||||||
|
if schema.Type != "object" || schema.AdditionalProperties || strings.Join(schema.Required, ",") != "summary,forecast_discussion" {
|
||||||
|
t.Fatalf("schema = %#v, want strict generated-text object", schema)
|
||||||
|
}
|
||||||
|
if _, ok := schema.Properties["confidence"]; !ok {
|
||||||
|
t.Fatalf("schema properties = %#v, want confidence", schema.Properties)
|
||||||
|
}
|
||||||
|
if id == "daily" && (schema.ID != "weatherreporter.daily.generated_text.schema.json" || schema.Title != "Daily GeneratedText") {
|
||||||
|
t.Fatalf("daily schema identity = %q/%q, want corrected Daily identity", schema.ID, schema.Title)
|
||||||
|
}
|
||||||
|
data[0] = 'x'
|
||||||
|
fresh, err := promptassets.Schema(id)
|
||||||
|
if err != nil || fresh[0] != '{' {
|
||||||
|
t.Fatalf("Schema() returned shared data or error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptkitInspectsEmbeddedPromptsOffline(t *testing.T) {
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||||
|
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"weather.daily_generated_text", "weather.today_generated_text", "weather.tomorrow_generated_text", "weather.hourly_generated_text"} {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
inspection, err := engine.InspectPrompt(context.Background(), id, "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPrompt() error = %v", err)
|
||||||
|
}
|
||||||
|
if inspection.PromptID != id || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
|
||||||
|
t.Fatalf("inspection = %#v", inspection)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptAssetsExcludeRetiredRuntimeSettings(t *testing.T) {
|
||||||
|
if err := fs.WalkDir(promptassets.PromptFS(), ".", func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(promptassets.PromptFS(), path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"local-heavy", "pipeline-weather/", "application/json", "repair_attempts:", "weather.daily_report"} {
|
||||||
|
if strings.Contains(string(data), unwanted) {
|
||||||
|
t.Fatalf("%s contains retired runtime setting %q", path, unwanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("walk embedded prompts: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/promptexec/copy.go
Normal file
80
internal/promptexec/copy.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package promptexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyPreparation(value Preparation) Preparation {
|
||||||
|
value.InputHashes = copyStringMap(value.InputHashes)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPreparationDebug(value *PreparationDebug) *PreparationDebug {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := *value
|
||||||
|
copy.RenderedMessages = append([]RenderedMessage(nil), value.RenderedMessages...)
|
||||||
|
copy.StructuredSchema = append([]byte(nil), value.StructuredSchema...)
|
||||||
|
copy.ParametersJSON = append([]byte(nil), value.ParametersJSON...)
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyExecution(value Execution) Execution {
|
||||||
|
value.InputHashes = copyStringMap(value.InputHashes)
|
||||||
|
value.Validation.Diagnostics = boundDiagnostics(value.Validation.Diagnostics)
|
||||||
|
value.RawOutput = append([]byte(nil), value.RawOutput...)
|
||||||
|
value.Debug = copyExecutionDebug(value.Debug)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyExecutionDebug(value *ExecutionDebug) *ExecutionDebug {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := *value
|
||||||
|
copy.RawOutput = append([]byte(nil), value.RawOutput...)
|
||||||
|
copy.ValidationDiagnostics = boundDiagnostics(value.ValidationDiagnostics)
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(value map[string]string) map[string]string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := make(map[string]string, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
copy[key] = item
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundDiagnostics(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(values) > maxValidationDiagnostics {
|
||||||
|
values = values[:maxValidationDiagnostics]
|
||||||
|
}
|
||||||
|
bounded := make([]string, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
bounded[index] = boundText(value, maxDiagnosticBytes)
|
||||||
|
}
|
||||||
|
return bounded
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundText(value string, limit int) string {
|
||||||
|
if limit <= 0 || value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value = strings.ToValidUTF8(value, "<22>")
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
value = value[:limit]
|
||||||
|
for len(value) > 0 && !utf8.ValidString(value) {
|
||||||
|
value = value[:len(value)-1]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
278
internal/promptexec/promptexec.go
Normal file
278
internal/promptexec/promptexec.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// Package promptexec defines Weatherreporter's provider-neutral prompt execution contract.
|
||||||
|
package promptexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxValidationDiagnostics = 10
|
||||||
|
maxDiagnosticBytes = 1024
|
||||||
|
maxErrorMessageBytes = 2048
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor inspects and executes configured prompts without exposing provider types.
|
||||||
|
// Inspection is side-effect-free. Execute invokes prepared exactly once after a
|
||||||
|
// successful preparation and before provider execution. If prepared returns an
|
||||||
|
// error, Execute must not call the provider. Completed validation rejection is
|
||||||
|
// returned as an Execution with a failed Validation status; operational failures
|
||||||
|
// return no Execution. Sensitive debug values are populated only when requested.
|
||||||
|
type Executor interface {
|
||||||
|
InspectPrompt(context.Context, string, string) (PromptInspection, error)
|
||||||
|
InspectProfile(context.Context, string) (ProfileInspection, error)
|
||||||
|
Execute(context.Context, ExecuteRequest, PreparationCallback) (*Execution, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInspection describes one exact prompt definition without selecting a profile.
|
||||||
|
type PromptInspection struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
DefaultProfileID string
|
||||||
|
Inputs []InputDefinition
|
||||||
|
Output OutputContract
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputDefinition describes one declared prompt input.
|
||||||
|
type InputDefinition struct {
|
||||||
|
Name string
|
||||||
|
Required bool
|
||||||
|
ContentType string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputContract summarizes the output requirements declared by a prompt.
|
||||||
|
type OutputContract struct {
|
||||||
|
Format string
|
||||||
|
ValidationMode string
|
||||||
|
SchemaPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileInspection describes the safe, selected execution identity for one profile.
|
||||||
|
type ProfileInspection struct {
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
CredentialRequired bool
|
||||||
|
APIKeyEnv string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteRequest selects one exact prompt execution. DataPackage is the exact
|
||||||
|
// YAML input; implementations must copy it before retaining it. DataPackagePath
|
||||||
|
// is provenance for the inline input, not a provider-readable file reference.
|
||||||
|
type ExecuteRequest struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
DataPackage []byte
|
||||||
|
DataPackagePath string
|
||||||
|
CaptureDebug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreparationCallback receives safe preparation provenance before provider work.
|
||||||
|
// The callback receives independent copies which it may retain or mutate.
|
||||||
|
type PreparationCallback func(Preparation, *PreparationDebug) error
|
||||||
|
|
||||||
|
// Preparation contains non-sensitive provenance from a completed preparation.
|
||||||
|
type Preparation struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
RenderedPromptHash string
|
||||||
|
InputHashes map[string]string
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
Output OutputContract
|
||||||
|
StartedAt time.Time
|
||||||
|
EndedAt time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
DataPackagePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreparationDebug contains content-rich preparation details for an explicitly
|
||||||
|
// enabled sensitive-debug destination. It must never be persisted routinely.
|
||||||
|
type PreparationDebug struct {
|
||||||
|
RenderedMessages []RenderedMessage
|
||||||
|
StructuredSchema []byte
|
||||||
|
Endpoint string
|
||||||
|
ParametersJSON []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedMessage is one fully rendered model message for sensitive debugging.
|
||||||
|
type RenderedMessage struct {
|
||||||
|
Role string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execution contains the completed result of one provider run. RawOutput is
|
||||||
|
// the generated content, not a provider transport response body. It is copied
|
||||||
|
// before return and must be persisted separately from routine metadata.
|
||||||
|
type Execution struct {
|
||||||
|
RunID string
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
RenderedPromptHash string
|
||||||
|
InputHashes map[string]string
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
GeneratedHash string
|
||||||
|
Usage TokenUsage
|
||||||
|
StartedAt time.Time
|
||||||
|
EndedAt time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
Validation Validation
|
||||||
|
DataPackagePath string
|
||||||
|
RawOutput []byte
|
||||||
|
Debug *ExecutionDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsage is provider-reported token accounting.
|
||||||
|
type TokenUsage struct {
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
CachedTokens int
|
||||||
|
CacheWriteTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation records a completed output validation check.
|
||||||
|
type Validation struct {
|
||||||
|
Status ValidationStatus
|
||||||
|
Mode string
|
||||||
|
SchemaPath string
|
||||||
|
Diagnostics []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidation returns a completed validation value with bounded diagnostics.
|
||||||
|
func NewValidation(status ValidationStatus, mode string, schemaPath string, diagnostics []string) Validation {
|
||||||
|
return Validation{
|
||||||
|
Status: status,
|
||||||
|
Mode: mode,
|
||||||
|
SchemaPath: schemaPath,
|
||||||
|
Diagnostics: boundDiagnostics(diagnostics),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationStatus identifies the completed validation state.
|
||||||
|
type ValidationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationPassed ValidationStatus = "passed"
|
||||||
|
ValidationFailed ValidationStatus = "failed"
|
||||||
|
ValidationSkipped ValidationStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionDebug contains content-rich execution details for explicitly enabled
|
||||||
|
// sensitive debugging. It must never be persisted routinely.
|
||||||
|
type ExecutionDebug struct {
|
||||||
|
RawOutput []byte
|
||||||
|
ValidationDiagnostics []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCategory classifies a project-owned operational failure.
|
||||||
|
type ErrorCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvalidConfiguration ErrorCategory = "invalid_configuration"
|
||||||
|
InvalidRequest ErrorCategory = "invalid_request"
|
||||||
|
PromptNotFound ErrorCategory = "prompt_not_found"
|
||||||
|
PromptLoad ErrorCategory = "prompt_load"
|
||||||
|
ProfileNotFound ErrorCategory = "profile_not_found"
|
||||||
|
ProfileLoad ErrorCategory = "profile_load"
|
||||||
|
MissingCredential ErrorCategory = "missing_credential"
|
||||||
|
ArtifactLoad ErrorCategory = "artifact_load"
|
||||||
|
PromptRender ErrorCategory = "prompt_render"
|
||||||
|
Capacity ErrorCategory = "capacity"
|
||||||
|
Generation ErrorCategory = "generation"
|
||||||
|
OperationalValidation ErrorCategory = "operational_validation"
|
||||||
|
ValidationRejected ErrorCategory = "validation_rejected"
|
||||||
|
Canceled ErrorCategory = "canceled"
|
||||||
|
DeadlineExceeded ErrorCategory = "deadline_exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error is a bounded safe error suitable for workflow and persistence records.
|
||||||
|
// Its optional cause remains available to errors.Is and errors.As but is never
|
||||||
|
// included in Error's text.
|
||||||
|
type Error struct {
|
||||||
|
category ErrorCategory
|
||||||
|
message string
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewError returns a classified error with a bounded, Weatherreporter-owned message.
|
||||||
|
func NewError(category ErrorCategory, message string, cause error) *Error {
|
||||||
|
messageLimit := maxErrorMessageBytes - len(category) - len(": ")
|
||||||
|
return &Error{category: category, message: boundText(message, messageLimit), cause: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.message == "" {
|
||||||
|
return string(e.category)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.category, e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap preserves an underlying error identity without exposing its text.
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category returns the stable classification.
|
||||||
|
func (e *Error) Category() ErrorCategory {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.category
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapacityError adds the safe backend identity to a capacity failure.
|
||||||
|
type CapacityError struct {
|
||||||
|
BackendID string
|
||||||
|
Err *Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCapacityError returns a classified capacity failure for backendID.
|
||||||
|
func NewCapacityError(backendID string, message string, cause error) *CapacityError {
|
||||||
|
return &CapacityError{BackendID: boundText(backendID, 256), Err: NewError(Capacity, message, cause)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CapacityError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.BackendID == "" {
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
return boundText(fmt.Sprintf("%s (backend %q)", e.Err.Error(), e.BackendID), maxErrorMessageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CapacityError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category returns Capacity for every capacity error.
|
||||||
|
func (e *CapacityError) Category() ErrorCategory { return Capacity }
|
||||||
|
|
||||||
|
// CategoryOf returns the classification carried by err, including wrapped errors.
|
||||||
|
func CategoryOf(err error) ErrorCategory {
|
||||||
|
var categorized interface{ Category() ErrorCategory }
|
||||||
|
if errors.As(err, &categorized) {
|
||||||
|
return categorized.Category()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
266
internal/promptexec/promptexec_test.go
Normal file
266
internal/promptexec/promptexec_test.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package promptexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeExecutor struct{}
|
||||||
|
|
||||||
|
func (fakeExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
|
||||||
|
return PromptInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
|
||||||
|
return ProfileInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeExecutor) Execute(context.Context, ExecuteRequest, PreparationCallback) (*Execution, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Executor = fakeExecutor{}
|
||||||
|
|
||||||
|
type lifecycleExecutor struct {
|
||||||
|
providerCalled bool
|
||||||
|
operationalFailure error
|
||||||
|
validationRejected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (executor *lifecycleExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
|
||||||
|
return PromptInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (executor *lifecycleExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
|
||||||
|
return ProfileInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (executor *lifecycleExecutor) Execute(_ context.Context, request ExecuteRequest, callback PreparationCallback) (*Execution, error) {
|
||||||
|
if executor.operationalFailure != nil {
|
||||||
|
return nil, executor.operationalFailure
|
||||||
|
}
|
||||||
|
preparation := Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, DataPackagePath: request.DataPackagePath}
|
||||||
|
var debug *PreparationDebug
|
||||||
|
if request.CaptureDebug {
|
||||||
|
debug = &PreparationDebug{RenderedMessages: []RenderedMessage{{Role: "user", Content: "sensitive rendered message"}}}
|
||||||
|
}
|
||||||
|
if callback != nil {
|
||||||
|
if err := callback(copyPreparation(preparation), copyPreparationDebug(debug)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
executor.providerCalled = true
|
||||||
|
status := ValidationPassed
|
||||||
|
if executor.validationRejected {
|
||||||
|
status = ValidationFailed
|
||||||
|
}
|
||||||
|
result := Execution{PromptID: request.PromptID, PromptVersion: request.PromptVersion, DataPackagePath: request.DataPackagePath, Validation: Validation{Status: status}, RawOutput: []byte("generated content")}
|
||||||
|
if request.CaptureDebug {
|
||||||
|
result.Debug = &ExecutionDebug{RawOutput: []byte("generated content")}
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorLifecycleFixtures(t *testing.T) {
|
||||||
|
request := ExecuteRequest{PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", DataPackagePath: "data_package.yaml"}
|
||||||
|
t.Run("callback failure prevents provider execution", func(t *testing.T) {
|
||||||
|
executor := &lifecycleExecutor{}
|
||||||
|
callbackError := errors.New("persistence failed")
|
||||||
|
result, err := executor.Execute(context.Background(), request, func(Preparation, *PreparationDebug) error { return callbackError })
|
||||||
|
if result != nil || !errors.Is(err, callbackError) || executor.providerCalled {
|
||||||
|
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("validation rejection is completed result", func(t *testing.T) {
|
||||||
|
executor := &lifecycleExecutor{validationRejected: true}
|
||||||
|
result, err := executor.Execute(context.Background(), request, nil)
|
||||||
|
if err != nil || result == nil || result.Validation.Status != ValidationFailed || !executor.providerCalled {
|
||||||
|
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("operational failure has no completed result", func(t *testing.T) {
|
||||||
|
failure := NewError(Generation, "generation failed", nil)
|
||||||
|
executor := &lifecycleExecutor{operationalFailure: failure}
|
||||||
|
result, err := executor.Execute(context.Background(), request, nil)
|
||||||
|
if result != nil || !errors.Is(err, failure) || executor.providerCalled {
|
||||||
|
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("debug requires explicit request", func(t *testing.T) {
|
||||||
|
executor := &lifecycleExecutor{}
|
||||||
|
var callbackDebug *PreparationDebug
|
||||||
|
result, err := executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
|
||||||
|
callbackDebug = debug
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || callbackDebug != nil || result.Debug != nil {
|
||||||
|
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
|
||||||
|
}
|
||||||
|
request.CaptureDebug = true
|
||||||
|
result, err = executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
|
||||||
|
callbackDebug = debug
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || callbackDebug == nil || result.Debug == nil {
|
||||||
|
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCategoriesAndCapacityError(t *testing.T) {
|
||||||
|
categories := []ErrorCategory{
|
||||||
|
InvalidConfiguration,
|
||||||
|
InvalidRequest,
|
||||||
|
PromptNotFound,
|
||||||
|
PromptLoad,
|
||||||
|
ProfileNotFound,
|
||||||
|
ProfileLoad,
|
||||||
|
MissingCredential,
|
||||||
|
ArtifactLoad,
|
||||||
|
PromptRender,
|
||||||
|
Capacity,
|
||||||
|
Generation,
|
||||||
|
OperationalValidation,
|
||||||
|
ValidationRejected,
|
||||||
|
Canceled,
|
||||||
|
DeadlineExceeded,
|
||||||
|
}
|
||||||
|
cause := errors.New("dependency details must not become safe error text")
|
||||||
|
for _, category := range categories {
|
||||||
|
t.Run(string(category), func(t *testing.T) {
|
||||||
|
err := NewError(category, "safe workflow failure", cause)
|
||||||
|
if err.Category() != category || CategoryOf(err) != category {
|
||||||
|
t.Fatalf("category = %q/%q, want %q", err.Category(), CategoryOf(err), category)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, cause) {
|
||||||
|
t.Fatal("errors.Is() = false, want preserved cause")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), cause.Error()) {
|
||||||
|
t.Fatalf("error leaks cause: %q", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity := NewCapacityError("local", "safe capacity failure", cause)
|
||||||
|
if capacity.Category() != Capacity || CategoryOf(capacity) != Capacity || capacity.BackendID != "local" {
|
||||||
|
t.Fatalf("capacity error = %#v", capacity)
|
||||||
|
}
|
||||||
|
if !errors.Is(capacity, cause) {
|
||||||
|
t.Fatal("capacity error does not preserve cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundDiagnosticAndErrorText(t *testing.T) {
|
||||||
|
longUTF8 := strings.Repeat("é", maxDiagnosticBytes)
|
||||||
|
values := make([]string, maxValidationDiagnostics+2)
|
||||||
|
for index := range values {
|
||||||
|
values[index] = longUTF8
|
||||||
|
}
|
||||||
|
values[0] = string([]byte{'a', 0xff, 'b'})
|
||||||
|
bounded := boundDiagnostics(values)
|
||||||
|
if len(bounded) != maxValidationDiagnostics {
|
||||||
|
t.Fatalf("diagnostics length = %d, want %d", len(bounded), maxValidationDiagnostics)
|
||||||
|
}
|
||||||
|
for index, value := range bounded {
|
||||||
|
if len(value) > maxDiagnosticBytes || !utf8.ValidString(value) {
|
||||||
|
t.Fatalf("diagnostic %d = %q, want valid UTF-8 within %d bytes", index, value, maxDiagnosticBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bounded[0] != "a<>b" {
|
||||||
|
t.Fatalf("invalid UTF-8 diagnostic = %q, want replacement", bounded[0])
|
||||||
|
}
|
||||||
|
validation := NewValidation(ValidationFailed, "json_schema", "daily.schema.json", values)
|
||||||
|
if len(validation.Diagnostics) != maxValidationDiagnostics || validation.Diagnostics[0] != "a<>b" {
|
||||||
|
t.Fatalf("validation = %#v, want bounded diagnostics", validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := NewError(Generation, strings.Repeat("é", maxErrorMessageBytes), nil)
|
||||||
|
if len(err.Error()) > maxErrorMessageBytes || !utf8.ValidString(err.Error()) {
|
||||||
|
t.Fatalf("error = %q, want valid UTF-8 within %d bytes", err, maxErrorMessageBytes)
|
||||||
|
}
|
||||||
|
capacity := NewCapacityError(strings.Repeat("x", 300), strings.Repeat("é", maxErrorMessageBytes), nil)
|
||||||
|
if len(capacity.Error()) > maxErrorMessageBytes || !utf8.ValidString(capacity.Error()) {
|
||||||
|
t.Fatalf("capacity error = %q, want valid UTF-8 within %d bytes", capacity, maxErrorMessageBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractCopiesMutableValues(t *testing.T) {
|
||||||
|
preparation := Preparation{InputHashes: map[string]string{"data_package": "input-hash"}}
|
||||||
|
preparationDebug := &PreparationDebug{
|
||||||
|
RenderedMessages: []RenderedMessage{{Role: "user", Content: "rendered input"}},
|
||||||
|
StructuredSchema: []byte("schema body"),
|
||||||
|
ParametersJSON: []byte(`{"temperature":0.2}`),
|
||||||
|
}
|
||||||
|
execution := Execution{
|
||||||
|
InputHashes: map[string]string{"data_package": "input-hash"},
|
||||||
|
Validation: Validation{Diagnostics: []string{"validation detail"}},
|
||||||
|
RawOutput: []byte("generated output"),
|
||||||
|
Debug: &ExecutionDebug{
|
||||||
|
RawOutput: []byte("provider output"),
|
||||||
|
ValidationDiagnostics: []string{"detailed validation"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
preparationCopy := copyPreparation(preparation)
|
||||||
|
debugCopy := copyPreparationDebug(preparationDebug)
|
||||||
|
executionCopy := copyExecution(execution)
|
||||||
|
preparation.InputHashes["data_package"] = "changed"
|
||||||
|
preparationDebug.RenderedMessages[0].Content = "changed"
|
||||||
|
preparationDebug.StructuredSchema[0] = 'x'
|
||||||
|
preparationDebug.ParametersJSON[0] = 'x'
|
||||||
|
execution.InputHashes["data_package"] = "changed"
|
||||||
|
execution.Validation.Diagnostics[0] = "changed"
|
||||||
|
execution.RawOutput[0] = 'x'
|
||||||
|
execution.Debug.RawOutput[0] = 'x'
|
||||||
|
execution.Debug.ValidationDiagnostics[0] = "changed"
|
||||||
|
|
||||||
|
if preparationCopy.InputHashes["data_package"] != "input-hash" {
|
||||||
|
t.Fatalf("preparation copy = %#v", preparationCopy)
|
||||||
|
}
|
||||||
|
if debugCopy.RenderedMessages[0].Content != "rendered input" || string(debugCopy.StructuredSchema) != "schema body" || string(debugCopy.ParametersJSON) != `{"temperature":0.2}` {
|
||||||
|
t.Fatalf("preparation debug copy = %#v", debugCopy)
|
||||||
|
}
|
||||||
|
if executionCopy.InputHashes["data_package"] != "input-hash" || executionCopy.Validation.Diagnostics[0] != "validation detail" || string(executionCopy.RawOutput) != "generated output" || string(executionCopy.Debug.RawOutput) != "provider output" || executionCopy.Debug.ValidationDiagnostics[0] != "detailed validation" {
|
||||||
|
t.Fatalf("execution copy = %#v", executionCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeContractValuesExcludeSensitiveFields(t *testing.T) {
|
||||||
|
preparation := Preparation{
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
PromptVersion: "1.0.0",
|
||||||
|
PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
InputHashes: map[string]string{"data_package": "input-hash"},
|
||||||
|
ProfileID: "configured-profile",
|
||||||
|
BackendID: "local",
|
||||||
|
ModelName: "model-name",
|
||||||
|
Output: OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: "daily.generated_text.schema.json"},
|
||||||
|
DataPackagePath: "data-packages/daily/data_package.yaml",
|
||||||
|
}
|
||||||
|
execution := Execution{
|
||||||
|
RunID: "run-id",
|
||||||
|
PromptID: preparation.PromptID,
|
||||||
|
PromptVersion: preparation.PromptVersion,
|
||||||
|
PromptHash: preparation.PromptHash,
|
||||||
|
RenderedPromptHash: preparation.RenderedPromptHash,
|
||||||
|
InputHashes: copyStringMap(preparation.InputHashes),
|
||||||
|
ProfileID: preparation.ProfileID,
|
||||||
|
BackendID: preparation.BackendID,
|
||||||
|
ModelName: preparation.ModelName,
|
||||||
|
GeneratedHash: "generated-hash",
|
||||||
|
RawOutput: []byte("generated content"),
|
||||||
|
}
|
||||||
|
text := preparation.PromptID + preparation.PromptVersion + preparation.PromptHash + preparation.RenderedPromptHash + preparation.ProfileID + preparation.BackendID + preparation.ModelName + preparation.Output.SchemaPath + preparation.DataPackagePath + execution.RunID + execution.GeneratedHash
|
||||||
|
for _, unwanted := range []string{"https://provider.example", "API_KEY_ENV", "rendered message", "schema body", "input body", "provider response body", "full parameters"} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("safe values contain %q: %s", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if string(execution.RawOutput) != "generated content" {
|
||||||
|
t.Fatalf("raw output = %q, want generated content", execution.RawOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,9 +101,9 @@ func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||||
req := validBuildRequest(t)
|
req := validBuildRequest(t)
|
||||||
req.Metadata.RunID = "20260529T100000Z_three_day"
|
req.Metadata.RunID = "20260529T100000Z_today"
|
||||||
req.Metadata.ReportID = report.ThreeDay
|
req.Metadata.ReportID = report.Today
|
||||||
req.Metadata.PromptID = "weather.three_day_outlook"
|
req.Metadata.PromptID = "weather.today_generated_text"
|
||||||
req.Modules = snapshotWithOutputs(t,
|
req.Modules = snapshotWithOutputs(t,
|
||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||||
@@ -115,8 +115,8 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
t.Fatalf("Build() error = %v", err)
|
t.Fatalf("Build() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg.Report.ID != report.ThreeDay {
|
if pkg.Report.ID != report.Today {
|
||||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
t.Fatalf("Report.ID = %q, want today", pkg.Report.ID)
|
||||||
}
|
}
|
||||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func dailyDefinition() Definition {
|
|||||||
ID: Daily,
|
ID: Daily,
|
||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_generated_text",
|
PromptID: "weather.daily_generated_text",
|
||||||
GenerationMode: GenerationModeGeneratedTextTemplate,
|
PromptVersion: "1.0.0",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
@@ -22,7 +22,6 @@ func dailyDefinition() Definition {
|
|||||||
"daily/{valid_start_date}/{run_id}.md",
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
},
|
},
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{Daily},
|
CompatiblePriorIDs: []ID{Daily},
|
||||||
Modules: dailyModules(),
|
Modules: dailyModules(),
|
||||||
resolve: resolveDaily,
|
resolve: resolveDaily,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user