Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53aa0b0a55 | |||
| fc8ddada9a | |||
| 13b06039b1 | |||
| b9080466a2 | |||
| 142f2f92e7 | |||
| 88fde0df7f | |||
| b985c5faac | |||
| d6829af32b | |||
| cd7b9aef2b | |||
| c3ebf06bd5 | |||
| 7884b9a6c3 | |||
| 17468cb8dd | |||
| 71b7a74d3d | |||
| 2c4c0bbd90 | |||
| 965f16d7a4 | |||
| fb891fad07 | |||
| 0516ee148d | |||
| e6450138c2 | |||
| 57aa27c9de | |||
| 166c4ce53b | |||
| 78fc461a75 | |||
| 5e492cf1fb | |||
| 79cba800ee | |||
| 302f5aba2d | |||
| 0314a302f1 | |||
| 707db5394c | |||
| 70cad789ea | |||
| 4b748c2e53 | |||
| 0b57d99a97 | |||
| 04b8358965 | |||
| f4e3a6f26c | |||
| 44ee389334 | |||
| ef2634c2cb | |||
| a18d5134c7 | |||
| 2bd921f247 | |||
| 360c665a3e | |||
| e2dd8d0e29 | |||
| e520ffb13b | |||
| f8beed04cf | |||
| 44af91cadf | |||
| a38d291f63 | |||
| 27849813db | |||
| 41b86109e3 | |||
| 13829cc65c | |||
| daf0c7efd7 | |||
| 9b4e53702b | |||
| 730929e2ed | |||
| 8e49ba88c7 | |||
| 8fafacf921 | |||
| 8bb7307f22 | |||
| c515529b3a | |||
| 3c1ebab289 | |||
| 2d956f7315 | |||
| 4d5a1d9709 | |||
| 1d3ea64541 | |||
| 706086e3de | |||
| 26a681e0b1 | |||
| 5139c1a586 | |||
| 0b869af75e | |||
| c3eeb298f0 | |||
| 6945306a2f | |||
| 4f52555389 | |||
| fa19452dec | |||
| 91e7e5f321 | |||
| 4bb3913276 | |||
| ab571cd8ab | |||
| 798e6f11c5 | |||
| c49c50bc8d | |||
| d328a1daa6 | |||
| 7ae3820e12 | |||
| a4ef76f17a | |||
| 5ed1e264fc | |||
| c025afcd1a | |||
| 2b06541ef8 | |||
| d92ff0ef48 | |||
| 880ad710ae | |||
| edde330390 | |||
| ae52606772 | |||
| 8a323d5574 | |||
| cfb64ded34 | |||
| 5ed448df11 | |||
| 725c1420dd | |||
| e5250bd6cb | |||
| 00fe0c3e96 | |||
| 6d2c097657 | |||
| e7c7262404 | |||
| 151c536cb9 | |||
| 2b1fb26e7d | |||
| 3c7383e2ce | |||
| eed47b4f68 | |||
| 6c185b8d0e | |||
| faf547e4a8 | |||
| acb476a142 | |||
| 606b4423f1 | |||
| 1716702c99 | |||
| e0229d9c90 | |||
| ccf6b66880 | |||
| b489c56a48 | |||
| d39e42de30 | |||
| d642791c10 | |||
| 236e3d16c4 | |||
| 4fa873983d | |||
| de1ae896b3 | |||
| 6173e50d25 | |||
| 3bca2f41f7 | |||
| af9cb0c0dc | |||
| 20c82776dc | |||
| f364ce773d | |||
| 0dc6a06cd3 | |||
| 2af6a5cfd2 | |||
| 0c4c575eea | |||
| 114f7f5f85 | |||
| 328c7a5693 | |||
| fe176a2abc | |||
| ab9218b124 | |||
| 8d6ab0eb56 | |||
| 76cd399c76 | |||
| bf1746a756 | |||
| 28bdc04fba | |||
| b67fae886e | |||
| 71a2eae87b | |||
| bd34ec57f8 | |||
| 97215ddb9b | |||
| dd7881acfb | |||
| ece31567b8 | |||
| 7ffc3dc603 | |||
| 4bdba6f2b7 | |||
| b184ca7cbd | |||
| 62a12dd661 | |||
| ac8d618111 | |||
| 5ddd3ee19c | |||
| 8be9b020d4 | |||
| 7f5a9c0357 | |||
| 7d591487e4 | |||
| 1250247986 | |||
| 117c5336ba | |||
| c5ec4f83b2 | |||
| 39c097a710 | |||
| 993120a9f2 | |||
| c20e285d5f | |||
| acbe22dcad | |||
| cc97ae186c | |||
| 51c35f7c22 | |||
| f014a078ee | |||
| 8c19ad763b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
# Compiled application binary and testing workspace
|
# Compiled application binary
|
||||||
/weatherreporter
|
/weatherreporter
|
||||||
/workspace
|
|
||||||
|
|
||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,25 +1,31 @@
|
|||||||
# weatherreporter
|
# weatherreporter
|
||||||
|
|
||||||
Weatherreporter is a Go CLI that turns normalized weather data into managed,
|
Weatherreporter is a Go CLI that turns normalized weather data into
|
||||||
human-facing Markdown reports.
|
human-facing Markdown reports.
|
||||||
|
|
||||||
It provides repeatable reports with inspectable local artifacts, so operators
|
It produces a Markdown report at an operator-owned destination and can upload
|
||||||
can review what was collected and generated for every run.
|
the completed output through Distributor. It can also compare explicitly
|
||||||
|
selected Promptkit profiles against one shared prepared report and publish a
|
||||||
|
local comparison bundle.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate today --out ./today.md
|
weatherreporter generate today
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure a Weather API endpoint first; see the
|
Configure a Weather API endpoint first; see the
|
||||||
[configuration reference](docs/config.md).
|
[configuration reference](docs/config.md). The report is written to
|
||||||
|
`today.md` in the current directory when `output.directory` is not configured.
|
||||||
|
Set that configuration value for an ordinary publication directory, or use
|
||||||
|
`--out` for one command. See the [CLI reference](docs/cli.md) and [operations
|
||||||
|
guide](docs/operations.md) for command and operating details.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [CLI reference](docs/cli.md)
|
- [CLI reference](docs/cli.md)
|
||||||
- [Configuration reference](docs/config.md)
|
- [Configuration reference](docs/config.md)
|
||||||
- [Operations guide](docs/operations.md)
|
- [Operations guide](docs/operations.md)
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
- [Comparison bundle contract](docs/integrations/comparison-bundle.md)
|
||||||
- [Development guide](docs/development.md)
|
- [Development guide](docs/development.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
|
|||||||
@@ -3,14 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
if err := runCommand(os.Args[1:], os.Stdout, os.Stderr, cli.Run); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
|
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runCommand(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error) error {
|
||||||
|
return runCommandWithSignalContext(args, stdout, stderr, runner, signal.NotifyContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
type signalContextFunc func(context.Context, ...os.Signal) (context.Context, context.CancelFunc)
|
||||||
|
|
||||||
|
func runCommandWithSignalContext(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error, signalContext signalContextFunc) error {
|
||||||
|
ctx, stop := signalContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
return runner(ctx, args, stdout, stderr)
|
||||||
|
}
|
||||||
|
|||||||
36
cmd/weatherreporter/main_test.go
Normal file
36
cmd/weatherreporter/main_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCommandBuildsCancelableSignalContext(t *testing.T) {
|
||||||
|
var signals []os.Signal
|
||||||
|
stopped := false
|
||||||
|
signalContext := func(parent context.Context, requested ...os.Signal) (context.Context, context.CancelFunc) {
|
||||||
|
signals = append([]os.Signal(nil), requested...)
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
cancel()
|
||||||
|
return ctx, func() {
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runCommandWithSignalContext(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
|
||||||
|
return ctx.Err()
|
||||||
|
}, signalContext)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("runCommandWithSignalContext() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
if len(signals) != 2 || signals[0] != os.Interrupt || signals[1] != syscall.SIGTERM {
|
||||||
|
t.Fatalf("requested signals = %#v, want Interrupt and SIGTERM", signals)
|
||||||
|
}
|
||||||
|
if !stopped {
|
||||||
|
t.Fatal("signal context stop function was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
58
cmd/weatherreporter/main_unix_test.go
Normal file
58
cmd/weatherreporter/main_unix_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCommandCancelsActionContextOnSignal(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
signal os.Signal
|
||||||
|
}{
|
||||||
|
{name: "Interrupt", signal: os.Interrupt},
|
||||||
|
{name: "Terminate", signal: syscall.SIGTERM},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runCommand(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
|
||||||
|
close(started)
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not receive an action context")
|
||||||
|
}
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := process.Signal(tt.signal); err != nil {
|
||||||
|
t.Fatalf("Signal(%v) error = %v", tt.signal, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("runCommand() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("interrupt did not cancel the action context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
100
docs/adr/0001-stateless-execution.md
Normal file
100
docs/adr/0001-stateless-execution.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 0001: Make Weatherreporter Execution Stateless
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Date: 2026-08-01
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Weather reports are ephemeral products. Forecasts and current conditions change
|
||||||
|
continuously, so the useful response to an old, failed, or superseded report is
|
||||||
|
normally a new generation rather than replaying or inspecting a prior run.
|
||||||
|
|
||||||
|
The existing run-addressed workspace retains module snapshots, prompt inputs,
|
||||||
|
execution receipts, generated text, rendered reports, metadata, and
|
||||||
|
notification receipts. That provenance store accumulates operational history
|
||||||
|
whose recovery and compatibility obligations are disproportionate to the value
|
||||||
|
of an ephemeral weather report. It also exists solely to support local Recent
|
||||||
|
Changes comparison for a rarely used report section.
|
||||||
|
|
||||||
|
The temporary roadmap that defined the feature scope and implementation plan
|
||||||
|
has been retired under the repository's documentation lifecycle. The
|
||||||
|
[architecture policy](../policy/architecture.md) defines the resulting system
|
||||||
|
invariants; this decision records their durable rationale.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Weatherreporter will operate as a stateless transformation pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Weather API input
|
||||||
|
-> deterministic facts and modules
|
||||||
|
-> Promptkit data package and generated text
|
||||||
|
-> repository-owned Markdown rendering
|
||||||
|
-> operator-owned report output
|
||||||
|
-> optional Distributor upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordinary invocations will retain intermediate values only for the active
|
||||||
|
process and will publish one operator-owned Markdown output atomically. A
|
||||||
|
failed or canceled generation must not truncate or partially replace an
|
||||||
|
existing selected output. Single-report Distributor notification follows
|
||||||
|
successful publication; batch notification follows successful publication of
|
||||||
|
every planned report.
|
||||||
|
|
||||||
|
Weatherreporter will remove local Recent Changes comparison instead of
|
||||||
|
retaining application state to support it. It will remove run-addressed
|
||||||
|
workspace artifacts, historical inspection, and backward-compatible workspace
|
||||||
|
decoding. RunIDs may remain active correlation and Distributor idempotency
|
||||||
|
values, but will not identify retained application history.
|
||||||
|
|
||||||
|
Explicit `--llm-debug-dir` capture remains the sole diagnostic-file exception.
|
||||||
|
The operator selects and manages that secure location; ordinary execution does
|
||||||
|
not create an implicit debug location or a general logging store, and debug
|
||||||
|
capture must continue to exclude credentials.
|
||||||
|
|
||||||
|
Any future forecast comparison must use a structured product supplied by the
|
||||||
|
Weather API rather than local Weatherreporter history. The proposed
|
||||||
|
[Upstream Forecast Change Product](../roadmap/future.md#upstream-forecast-change-product)
|
||||||
|
defines the required upstream direction. A future integration must not add a
|
||||||
|
local snapshot fallback.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Retain The Bounded Current-State Design
|
||||||
|
|
||||||
|
Retaining a managed workspace with current metadata, receipts, and snapshots
|
||||||
|
would preserve inspection and local comparison, but keeps an application-owned
|
||||||
|
history subsystem, artifact compatibility burden, and recovery surface that do
|
||||||
|
not match the report lifecycle.
|
||||||
|
|
||||||
|
### Time-Based Retention
|
||||||
|
|
||||||
|
Expiring workspace material after a fixed period reduces accumulation but still
|
||||||
|
requires retention policy, cleanup behavior, failure handling, and historical
|
||||||
|
format support. It does not remove the mismatch between retained provenance and
|
||||||
|
ephemeral report products.
|
||||||
|
|
||||||
|
### Bounded Run History
|
||||||
|
|
||||||
|
Keeping only a fixed number of prior runs limits storage volume but still makes
|
||||||
|
Weatherreporter responsible for run selection, comparison, inspection, and
|
||||||
|
state migration. It also creates arbitrary history gaps without establishing an
|
||||||
|
authoritative forecast baseline.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The CLI, configuration, prompt-input, workspace, and inspection contracts will
|
||||||
|
change together. Legacy workspace material will not be migrated, decoded, or
|
||||||
|
automatically deleted; operators remain responsible for any desired cleanup.
|
||||||
|
|
||||||
|
Current action results will carry active identity, selected profile, safe
|
||||||
|
effective model information, output location, notification result, and safe
|
||||||
|
errors instead of historical artifact paths. Tests will protect atomic output,
|
||||||
|
batch and notification ordering, explicit secure debug capture, and the
|
||||||
|
absence of ordinary application-managed state.
|
||||||
|
|
||||||
|
This decision deliberately leaves the Weather API responsible for any future
|
||||||
|
forecast-history comparison. It avoids a cache, archive, retention engine,
|
||||||
|
manifest, resume mechanism, or replacement inspection surface in
|
||||||
|
Weatherreporter.
|
||||||
205
docs/cli.md
205
docs/cli.md
@@ -1,17 +1,19 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` generates weather reports, runs report batches, and inspects
|
`weatherreporter` generates Markdown weather reports, runs report batches, and
|
||||||
artifacts already stored in its workspace.
|
compares explicitly selected Promptkit profiles against one prepared report. It
|
||||||
|
has no command for inspecting prior runs or application-owned state.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate today --out ./today.md
|
weatherreporter generate today
|
||||||
```
|
```
|
||||||
|
|
||||||
The command uses the configured Weather API and writes an extra Markdown copy
|
The command uses the configured Weather API and atomically writes `today.md`.
|
||||||
at `./today.md`. See the [configuration reference](config.md) to supply the
|
With no configured output directory, it writes in the current directory. See
|
||||||
required Weather API endpoint.
|
the [configuration reference](config.md) to supply the required Weather API
|
||||||
|
endpoint and choose an ordinary output directory.
|
||||||
|
|
||||||
## Commands And Usage
|
## Commands And Usage
|
||||||
|
|
||||||
@@ -24,12 +26,7 @@ weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [-
|
|||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
|
||||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
|
||||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
|
||||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
|
||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`weatherreporter --version` prints the version embedded in the executable.
|
`weatherreporter --version` prints the version embedded in the executable.
|
||||||
@@ -38,86 +35,153 @@ builds report `development`.
|
|||||||
|
|
||||||
| Command | Contract |
|
| Command | Contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. |
|
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. Its default filename is `daily-YYYY-MM-DD.md`. |
|
||||||
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. |
|
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. Its default filename is `today.md`. |
|
||||||
| `generate tomorrow` | Uses the next local civil day and accepts the common generate flags. |
|
| `generate tomorrow` | Uses the next local civil day and writes `tomorrow.md` by default. |
|
||||||
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--hours`, or `--duration`. |
|
| `generate hourly` | Covers the next six hours in the effective report timezone and writes `hourly.md` by default. It does not accept `--date`, `--hours`, or `--duration`. |
|
||||||
| `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 beneath the configured output directory, or the current directory when none is configured. `--out-dir` selects another directory. `--out` is not accepted. |
|
||||||
|
| `compare REPORT` | Accepts `daily`, `today`, `tomorrow`, or `hourly`. It requires at least two distinct, nonblank `--profile` values in their supplied order. Daily requires `--date`; Today accepts it optionally; Tomorrow and Hourly do not accept it. |
|
||||||
|
|
||||||
`generate` accepts the four 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`. `compare` always requires explicit profile
|
||||||
notification sequencing are described in the [operations guide](operations.md).
|
selection: `promptkit.profile` is not used as a comparison default. Batch
|
||||||
|
membership and notification ordering are described in the
|
||||||
|
[operations guide](operations.md).
|
||||||
|
|
||||||
## Output, Errors, And Quiet Mode
|
## Output, Errors, And Quiet Mode
|
||||||
|
|
||||||
Action commands (`generate` and `run`) write a JSON summary to stdout unless
|
For `generate`, the report's default filename is placed beneath
|
||||||
`--quiet` is set. `run` also writes compact per-report and batch status lines
|
`output.directory` when configured, otherwise the current directory. `--out
|
||||||
to stderr. A pre-run error, such as an invalid flag, missing required argument,
|
PATH` selects one complete output file instead. A relative path is resolved
|
||||||
or configuration-load failure, produces no partial JSON summary. When an action
|
from the current directory; an absolute path is used as given. For a batch,
|
||||||
fails after it has produced a result, its summary has `"status": "failed"` and
|
the configured directory has the same role and `--out-dir PATH` selects its
|
||||||
an `error` field.
|
output directory instead. For `compare`, `--out-dir PATH` selects one exact
|
||||||
|
bundle directory; otherwise the report-derived comparison directory is placed
|
||||||
|
beneath the configured directory or current directory. `--replace` is required
|
||||||
|
to replace an existing nonempty recognized comparison bundle. See the
|
||||||
|
[configuration reference](config.md) for the field's validation and path rules
|
||||||
|
and the [comparison bundle contract](integrations/comparison-bundle.md) for the
|
||||||
|
bundle format.
|
||||||
|
|
||||||
|
Outputs are written atomically. A generation, rendering, write, or cancellation
|
||||||
|
failure before publication leaves an existing destination unchanged. A
|
||||||
|
notification failure occurs after publication, so the newly written output
|
||||||
|
remains available.
|
||||||
|
|
||||||
|
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
|
||||||
|
cancellation reach the action before exiting; when the action has a result, it
|
||||||
|
emits the usual failed summary and exits nonzero. A canceled batch retains any
|
||||||
|
reports that were already published, marks interrupted and unstarted reports
|
||||||
|
as `canceled`, skips batch notification, and identifies cancellation separately
|
||||||
|
from report failures.
|
||||||
|
|
||||||
|
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
|
||||||
|
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
|
||||||
|
status lines to stderr. A pre-run error, such as an invalid flag, missing
|
||||||
|
required argument, or configuration-load failure, produces no partial JSON
|
||||||
|
summary. When an action fails after it has produced a result, its summary has
|
||||||
|
`"status": "failed"` and an `error` field.
|
||||||
|
|
||||||
`--quiet` is supported by action commands only. It suppresses action summaries
|
`--quiet` is supported by action commands only. It suppresses action summaries
|
||||||
and routine batch status output; it does not suppress command errors.
|
and routine batch status output; it does not suppress command errors.
|
||||||
|
|
||||||
Inspection commands always write their requested JSON value to stdout and do
|
|
||||||
not accept `--quiet`.
|
|
||||||
|
|
||||||
### Generate Summary
|
### Generate Summary
|
||||||
|
|
||||||
A generate summary always identifies the command, report, run, generation
|
A generate summary identifies the command, report, run, generation time, valid
|
||||||
time, valid period, and status:
|
period, prompt version, timezone, and status. Successful output has an absolute
|
||||||
|
`outputPath`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command": "generate",
|
"command": "generate",
|
||||||
"reportId": "today",
|
"reportId": "today",
|
||||||
"reportName": "Today Report",
|
|
||||||
"promptId": "weather.today_generated_text",
|
"promptId": "weather.today_generated_text",
|
||||||
|
"promptVersion": "2.0.0",
|
||||||
"runId": "20260529T120000.000000000Z_today",
|
"runId": "20260529T120000.000000000Z_today",
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"generatedAt": "2026-05-29T12:00:00Z",
|
"timezone": "America/Chicago",
|
||||||
"validPeriod": {
|
"outputPath": "/srv/weather/today.md"
|
||||||
"start": "2026-05-29T00:00:00-05:00",
|
|
||||||
"end": "2026-05-30T00:00:00-05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When available, the summary also includes `reportPath`, `metadataPath`,
|
When available, the summary also includes the effective `profileId`,
|
||||||
`dataPackagePath`, `preparationPath`, `executionPath`, `generatedTextRawPath`,
|
`backendId`, `modelName`, `sourceWarnings`, `validationStatus`, requested
|
||||||
`generatedTextPath`, `renderContextPath`, and `llmDebugPath`. `outputPath` is included only
|
`llmDebugPath`, and compact Distributor `notification` result. It does not
|
||||||
when `--out` wrote an extra copy. Distributor notification, when attempted,
|
include historical or transient artifact paths such as metadata, prompt input,
|
||||||
adds `notificationPath` and may add a compact `notification` object.
|
raw generated text, render context, or notification receipts.
|
||||||
|
|
||||||
### Run Summary And Stderr
|
### Run Summary And Stderr
|
||||||
|
|
||||||
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
|
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
|
||||||
`total`, `succeeded`, `failed`, and a `reports` array. It may also contain a
|
`total`, `succeeded`, `failed`, and a `reports` array. Each report item includes
|
||||||
top-level `notification` object and `error`. Batch status is `failed` if any
|
its identity, status, effective profile and model details when available,
|
||||||
report or the batch notification fails.
|
source warnings, validation status, and absolute `outputPath` after publication.
|
||||||
|
The top-level summary may also contain a batch `notification` object and
|
||||||
|
`error`. Batch status is `failed` if any report or the batch notification fails.
|
||||||
|
The `total`, `succeeded`, and `failed` counters describe report items only, so
|
||||||
|
a failed batch notification can leave `failed` at `0` while the top-level
|
||||||
|
notification and action status are `failed`.
|
||||||
|
|
||||||
|
When cancellation stops a batch, the summary also includes a nonzero
|
||||||
|
`canceled` count. Canceled reports have `"status": "canceled"`; they are not
|
||||||
|
included in `failed`, and the action still has failed status and exits nonzero.
|
||||||
|
|
||||||
Without `--quiet`, batch status lines use this form:
|
Without `--quiet`, batch status lines use this form:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
report=today status=succeeded output="reports/today.md"
|
report=today status=succeeded output="/srv/weather/reports/today.md"
|
||||||
batch=morning total=2 succeeded=2 failed=0
|
batch=morning total=2 succeeded=2 failed=0 canceled=0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Compare Summary
|
||||||
|
|
||||||
|
A comparison summary contains these fields in this order: `command`,
|
||||||
|
`comparisonId`, `reportId`, `reportName`, `promptId`, `promptVersion`,
|
||||||
|
`promptHash`, `status`, `startedAt`, `finishedAt`, `timezone`, `validPeriod`,
|
||||||
|
`outputDirectory`, `manifestPath`, `dataPackagePath`, `total`, `succeeded`,
|
||||||
|
`failed`, `results`, and optional `error`. Published artifact paths and each
|
||||||
|
successful `results[].reportPath` are absolute. `results` preserves the
|
||||||
|
supplied profile order and each item contains `position`, `profileId`, optional
|
||||||
|
`backendId`, `modelName`, `status`, optional `validationStatus`, optional
|
||||||
|
`reportPath`, optional `llmDebugPath`, and optional safe `error`.
|
||||||
|
|
||||||
|
The comparison status is `succeeded` only when every selected profile succeeds
|
||||||
|
and the bundle is published. Individual profile failures still publish a
|
||||||
|
complete partial bundle and return a failed command result. Cancellation or a
|
||||||
|
failure before publication omits the artifact paths and returns a safe
|
||||||
|
top-level error; the resolved `outputDirectory` and finalized timestamp remain
|
||||||
|
when available. The safe error includes only a category and message: aggregate
|
||||||
|
and unclassified application failures use `application`; cancellation uses
|
||||||
|
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
|
||||||
|
published Promptkit category; destination failures use `destination_<kind>`;
|
||||||
|
and committed cleanup failures use `publication_cleanup` with a message that
|
||||||
|
states whether a complete prior bundle, partial remnants, or no prior bundle
|
||||||
|
remains, or that recovery state could not be inspected. It does not expose
|
||||||
|
provider diagnostics, filesystem causes, or recovery paths. See the
|
||||||
|
[comparison bundle contract](integrations/comparison-bundle.md) for durable
|
||||||
|
artifact fields and failure invariants.
|
||||||
|
|
||||||
|
If the bundle is published but cleanup of its replaced prior bundle fails, the
|
||||||
|
summary still includes the published artifact paths and has status `failed`.
|
||||||
|
Its JSON error is `publication_cleanup`; the returned command error identifies
|
||||||
|
a recovery path only when cleanup left a sibling behind. Only a reported
|
||||||
|
complete prior bundle is a rollback artifact.
|
||||||
|
|
||||||
## Flag Reference
|
## Flag Reference
|
||||||
|
|
||||||
| Flag | Accepted by | Meaning |
|
| Flag | Accepted by | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `-h`, `--help` | top level | Show help. |
|
| `-h`, `--help` | top level, `compare` | Show help without loading configuration or contacting a provider. |
|
||||||
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
|
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
|
||||||
| `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
|
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
|
||||||
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` for this command. |
|
| `--tz NAME` | `generate`, `run`, `compare` | Override `weather_api.timezone` for this command. |
|
||||||
| `--out PATH` | every `generate` command | Write an extra Markdown report copy. |
|
| `--out PATH` | every `generate` command | Write the report to this complete file destination instead of the configured or current-directory default. |
|
||||||
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics outside the managed workspace. The path must be absolute. |
|
| `--llm-debug-dir PATH` | every `generate`, `run`, and `compare` command | On Unix hosts, write requested sensitive prompt diagnostics under this absolute path. Other hosts fail closed when the flag is requested. |
|
||||||
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
|
| `--profile PROFILE` | `compare` | Select one explicit profile. Repeat at least twice with distinct, nonblank IDs. |
|
||||||
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
|
| `--out-dir PATH` | `run morning`, `run evening`, `compare` | Write batch reports beneath this directory, or select the exact comparison directory. |
|
||||||
| `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
|
| `--replace` | `compare` | Authorize replacement of a recognized nonempty comparison bundle. |
|
||||||
| `--limit N` | `inspect reports` | Maximum runs to list. Defaults to `20`; `0` means no limit. |
|
| `--quiet` | `generate`, `run`, `compare` | Suppress all action summaries and routine batch status output. |
|
||||||
|
| `--date YYYY-MM-DD` | `generate daily`, `generate today`, `compare daily`, `compare today` | Required for Daily; optional for Today. |
|
||||||
|
|
||||||
Distributor notification is configured through `notify.distributor`; there are
|
Distributor notification is configured through `notify.distributor`; there are
|
||||||
no Distributor-specific CLI flags. See the [configuration reference](config.md).
|
no Distributor-specific CLI flags. See the [configuration reference](config.md).
|
||||||
@@ -125,33 +189,10 @@ no Distributor-specific CLI flags. See the [configuration reference](config.md).
|
|||||||
## Invocation Examples
|
## Invocation Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29
|
||||||
weatherreporter generate today --date 2026-05-29 --out ./today.md
|
weatherreporter generate today --out ./reports/today.md
|
||||||
weatherreporter generate hourly --out ./hourly.md
|
weatherreporter generate hourly --out /srv/weather/hourly.md
|
||||||
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
|
weatherreporter compare daily --date 2026-05-29 --profile weather-light --profile weather-balanced --out-dir ./comparison-daily-2026-05-29
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inspection Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter inspect reports --limit 10
|
|
||||||
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
|
||||||
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
|
||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
|
||||||
```
|
|
||||||
|
|
||||||
| Command | JSON returned |
|
|
||||||
| --- | --- |
|
|
||||||
| `inspect reports` | Recent generated runs, including artifact paths and source-warning counts. |
|
|
||||||
| `inspect metadata RUN_ID` | Persisted metadata for the run. |
|
|
||||||
| `inspect modules RUN_ID` | The run's persisted ordered module snapshot. |
|
|
||||||
| `inspect data-package RUN_ID` | The run's persisted prompt data package. |
|
|
||||||
| `inspect prior RUN_ID` | Prior comparable snapshot metadata, or `null` when none exists. |
|
|
||||||
| `inspect sources RUN_ID` | Source provenance and source warnings without full weather payloads. |
|
|
||||||
|
|
||||||
Inspection is read-only: it does not collect weather data or invoke Promptkit.
|
|
||||||
See the [operations guide](operations.md) for artifact lifecycle
|
|
||||||
and recovery.
|
|
||||||
|
|||||||
134
docs/config.md
134
docs/config.md
@@ -13,8 +13,8 @@ explicit `--config PATH` must exist. Values are applied in this order:
|
|||||||
2. the configuration file, when present; and
|
2. the configuration file, when present; and
|
||||||
3. the `--units` and `--tz` command-line overrides.
|
3. the `--units` and `--tz` command-line overrides.
|
||||||
|
|
||||||
Environment variables do not override configuration fields. Output flags write
|
Environment variables do not override configuration fields. Output flags select
|
||||||
extra report copies for a command and do not change configuration.
|
operator-owned destinations for one command and do not change configuration.
|
||||||
|
|
||||||
## Maintained Examples
|
## Maintained Examples
|
||||||
|
|
||||||
@@ -22,8 +22,12 @@ extra report copies for a command and do not change configuration.
|
|||||||
collection and generation configuration.
|
collection and generation configuration.
|
||||||
- [config.yml](../examples/config.yml) is a representative production-oriented
|
- [config.yml](../examples/config.yml) is a representative production-oriented
|
||||||
configuration using synthetic endpoints and no credentials.
|
configuration using synthetic endpoints and no credentials.
|
||||||
|
- [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml)
|
||||||
|
is a complete endpoint-only override for the embedded `weather-light`
|
||||||
|
profile.
|
||||||
|
|
||||||
Both files are loaded by the configuration test suite.
|
The configuration examples are loaded by the configuration test suite. The
|
||||||
|
profile example is inspected through the Promptkit adapter test suite.
|
||||||
|
|
||||||
## Minimal Configuration
|
## Minimal Configuration
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ All omitted fields use their built-in defaults.
|
|||||||
|
|
||||||
| Field | Default | Rules |
|
| Field | Default | Rules |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
|
| `base_url` | empty | Absolute HTTP(S) Weather API URL. Required for collection and generation. |
|
||||||
| `timeout` | `10s` | Must be greater than zero. |
|
| `timeout` | `10s` | Must be greater than zero. |
|
||||||
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
||||||
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
|
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
|
||||||
@@ -49,7 +53,11 @@ All omitted fields use their built-in defaults.
|
|||||||
| `format` | `json` | Required and must be `json`. |
|
| `format` | `json` | Required and must be `json`. |
|
||||||
|
|
||||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`,
|
||||||
|
and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an
|
||||||
|
optional two-digit minute component with or without a colon. Hours must be
|
||||||
|
from `00` through `23`, minutes from `00` through `59`, so the largest accepted
|
||||||
|
offset magnitude is `23:59`.
|
||||||
|
|
||||||
### `location`
|
### `location`
|
||||||
|
|
||||||
@@ -68,8 +76,10 @@ The prompt-facing location timezone is derived from the effective
|
|||||||
### `secrets`
|
### `secrets`
|
||||||
|
|
||||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||||
is set, every regular file directly in that directory is loaded after the file
|
is set, every regular file directly in that directory is staged after the file
|
||||||
and command-line overrides. A file basename must match
|
and command-line overrides, then applied only after the complete configuration
|
||||||
|
has validated successfully. A rejected load leaves the existing environment
|
||||||
|
unchanged. A file basename must match
|
||||||
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
|
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
|
||||||
file contents replace any existing value. One trailing LF or CRLF is removed.
|
file contents replace any existing value. One trailing LF or CRLF is removed.
|
||||||
|
|
||||||
@@ -77,6 +87,32 @@ Missing directories, unreadable files, subdirectories, symlinks, non-regular
|
|||||||
files, and invalid names fail configuration loading. Put only secret values in
|
files, and invalid names fail configuration loading. Put only secret values in
|
||||||
this directory, never in the YAML file.
|
this directory, never in the YAML file.
|
||||||
|
|
||||||
|
### `output`
|
||||||
|
|
||||||
|
`output.directory` selects the ordinary operator-owned publication directory
|
||||||
|
for individual reports, batches, and the default parent of comparison bundles.
|
||||||
|
|
||||||
|
| Field | Default | Rules |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `directory` | empty | An omitted or empty value uses the invocation working directory. A nonempty value must contain at least one non-whitespace character. |
|
||||||
|
|
||||||
|
The configured value is preserved while configuration loads: it is not cleaned,
|
||||||
|
made absolute, inspected, created, or expanded through environment variables or
|
||||||
|
a home-directory shortcut. At execution, an absolute directory is used as
|
||||||
|
given; a relative directory resolves from the invocation working directory, not
|
||||||
|
from the configuration file's location. A missing directory is created when a
|
||||||
|
report is successfully published. An existing non-directory or an uninspectable
|
||||||
|
path fails output preflight before prompt inspection, weather collection, or
|
||||||
|
publication.
|
||||||
|
|
||||||
|
For one `generate` command, `--out` is a complete file destination and takes
|
||||||
|
precedence over `output.directory`. For `run`, `--out-dir` takes precedence.
|
||||||
|
For `compare`, `--out-dir` selects its exact bundle directory; without it, the
|
||||||
|
comparison's report-derived directory is placed beneath `output.directory`.
|
||||||
|
Those explicit flags do not inspect or rebase beneath the configured directory.
|
||||||
|
See the [CLI reference](cli.md) for command selection and the [operations
|
||||||
|
guide](operations.md) for publication and failure handling.
|
||||||
|
|
||||||
### `notify.distributor`
|
### `notify.distributor`
|
||||||
|
|
||||||
Distributor notification is disabled by default. Its fields are:
|
Distributor notification is disabled by default. Its fields are:
|
||||||
@@ -84,7 +120,7 @@ Distributor notification is disabled by default. Its fields are:
|
|||||||
| Field | Default | Rules when notification is enabled |
|
| Field | Default | Rules when notification is enabled |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `enabled` | `false` | Activates Distributor notification validation. |
|
| `enabled` | `false` | Activates Distributor notification validation. |
|
||||||
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
|
| `endpoint` | `https://distributor.example.com` | Must be an absolute HTTP(S) base URL with a host and no userinfo, query, or fragment. A path prefix is allowed. |
|
||||||
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
||||||
| `timeout` | `30s` | Must be greater than zero. |
|
| `timeout` | `30s` | Must be greater than zero. |
|
||||||
| `failure_policy` | `error` | Must be `error`. |
|
| `failure_policy` | `error` | Must be `error`. |
|
||||||
@@ -99,6 +135,14 @@ Distributor notification is disabled by default. Its fields are:
|
|||||||
The upload token is read from the environment variable named by `token_env`.
|
The upload token is read from the environment variable named by `token_env`.
|
||||||
Use `secrets.directory` when a file-backed secret is appropriate.
|
Use `secrets.directory` when a file-backed secret is appropriate.
|
||||||
|
|
||||||
|
When notification is enabled, Weatherreporter validates the Distributor endpoint
|
||||||
|
before prompt inspection, weather collection, or output publication. Use an
|
||||||
|
HTTP(S) base URL such as `https://distributor.example.com/archive`; do not put
|
||||||
|
credentials, a query string, or a fragment in the endpoint.
|
||||||
|
|
||||||
|
When notification is enabled, each rendered single-report pipeline ID, bundle
|
||||||
|
ID, and idempotency key must contain at least one non-whitespace character.
|
||||||
|
|
||||||
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`,
|
||||||
@@ -125,48 +169,58 @@ The default paths are:
|
|||||||
| `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` |
|
||||||
|
|
||||||
See the [operations guide](operations.md) for notification timing, uploaded
|
See the [operations guide](operations.md) for notification timing, uploaded
|
||||||
artifact selection, and failure handling.
|
output selection, and failure handling.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||||
Hourly forecast data is required for generated reports. Supported optional
|
Hourly forecast data is required for generated reports and cannot have a
|
||||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
source-specific policy. Supported optional source keys are `observations`,
|
||||||
`weather_story`, and `spc_convective_outlooks`.
|
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||||
|
`spc_convective_outlooks`; any other key is rejected.
|
||||||
|
|
||||||
### `promptkit`
|
### `promptkit`
|
||||||
|
|
||||||
Promptkit configuration selects the executor and prompt/profile checks for
|
Promptkit configuration selects the executor and prompt/profile checks for
|
||||||
every `generate` and `run` command. A top-level `scriptorium:` configuration
|
every `generate`, `run`, and `compare` command. A top-level `scriptorium:` configuration
|
||||||
key is rejected with a migration error; it is not translated or ignored.
|
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
|
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||||
individual `generate` or `run` command when explicitly needed.
|
individual `generate`, `run`, or `compare` command when explicitly needed.
|
||||||
|
See [optional prompt debug capture](operations.md#optional-prompt-debug-capture)
|
||||||
|
for platform availability, security, and retention requirements.
|
||||||
|
|
||||||
| Field | Default | Rules |
|
| Field | Default | Rules |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `profile` | empty | Optional explicit execution profile. Otherwise the prompt's declared default is used. |
|
| `profile` | empty | Optional global profile selection for every report in one command. When empty, each exact prompt version selects its declared default. |
|
||||||
| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. |
|
| `profile_file` | empty | Optional external Promptkit profile file. It cannot be combined with `profile_dir`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
|
||||||
| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. |
|
| `profile_dir` | empty | Optional external Promptkit profile directory. It cannot be combined with `profile_file`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
|
||||||
| `timeout` | `2m` | Must be greater than zero. |
|
| `timeout` | `2m` | Must be greater than zero. |
|
||||||
| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. |
|
| `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. |
|
| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. |
|
||||||
|
|
||||||
### `workspace`
|
`profile` selects an ID; `profile_file` and `profile_dir` supply definitions.
|
||||||
|
They are separate decisions. An explicit `profile` applies to every selected
|
||||||
|
report. Otherwise Hourly selects `weather-light`, while Daily, Today, and
|
||||||
|
Tomorrow select `weather-balanced` through their exact `2.0.0` prompt
|
||||||
|
definitions.
|
||||||
|
|
||||||
| Field | Default |
|
Promptkit resolves a selected profile definition from a test or embedding
|
||||||
| --- | --- |
|
consumer's explicit in-memory profile, then the configured `profile_file` or
|
||||||
| `root` | `workspace` |
|
`profile_dir`, then Weatherreporter's embedded catalog, and finally Promptkit's
|
||||||
| `snapshots_dir` | `snapshots` |
|
built-in catalog. Sources provide complete definitions; fields are never
|
||||||
| `reports_dir` | `reports` |
|
merged. A matching malformed external profile fails rather than using the
|
||||||
| `data_packages_dir` | `data-packages` |
|
embedded definition. The [Promptkit integration guide](integrations/promptkit.md)
|
||||||
| `preflight_dir` | `preflight` |
|
owns the catalog and precedence details.
|
||||||
| `notifications_dir` | `notifications` |
|
|
||||||
|
|
||||||
`workspace.root` is required. Each workspace subdirectory must be a relative
|
To replace the default Hourly definition with a local OpenAI-compatible
|
||||||
path that stays within the root. See the [operations guide](operations.md) for
|
endpoint, set `profile_file` to a copy of
|
||||||
the managed workspace layout and lifecycle.
|
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml).
|
||||||
|
The example has no credential and should be edited for the local endpoint and
|
||||||
|
model before use. An alternative profile may use `backend: local`; in that
|
||||||
|
case `promptkit.local.endpoint` supplies the conventional local backend
|
||||||
|
endpoint.
|
||||||
|
|
||||||
### `dayparts`
|
### `dayparts`
|
||||||
|
|
||||||
@@ -176,25 +230,21 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
|
|||||||
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||||
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
||||||
|
|
||||||
### `recent_change`
|
Names remain display text, but each name must have a distinct canonical
|
||||||
|
identity. Canonicalization trims whitespace, lowercases letters, and collapses
|
||||||
| Field | Default |
|
punctuation and whitespace to underscores; for example, `Morning`,
|
||||||
| --- | --- |
|
`morning!`, and `morning` conflict. Planning recognizes the canonical
|
||||||
| `temperature_degrees` | `5` |
|
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
|
||||||
| `precip_probability_points` | `20` |
|
their display capitalization or punctuation.
|
||||||
| `wind_gust_miles_per_hour` | `10` |
|
|
||||||
| `precip_timing_shift_minutes` | `120` |
|
|
||||||
|
|
||||||
These thresholds control when Recent Changes are included in prompt input for a
|
|
||||||
prior comparable module snapshot.
|
|
||||||
|
|
||||||
### `reports`
|
### `reports`
|
||||||
|
|
||||||
`reports` optionally overrides a report's ordered deterministic modules and
|
`reports` optionally overrides a report's ordered deterministic modules and
|
||||||
Distributor path templates. Omit a report entry to retain its defaults.
|
Distributor path templates. Omit a report entry to retain its defaults.
|
||||||
|
|
||||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
|
||||||
and underscores are equivalent.
|
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
|
||||||
|
lookup.
|
||||||
|
|
||||||
Each report entry can contain:
|
Each report entry can contain:
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ 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, executes Promptkit for
|
deterministic report facts and module snapshots, executes Promptkit for
|
||||||
single-report generated text, renders managed Markdown reports, and can upload completed
|
single-report generated text, renders Markdown reports, and can upload completed
|
||||||
reports through Distributor. Start with the [README](../README.md) for product
|
operator-owned outputs through Distributor. Start with the [README](../README.md) for product
|
||||||
context and the [architecture policy](policy/architecture.md) for system
|
context and the [architecture policy](policy/architecture.md) for system
|
||||||
boundaries and invariants.
|
boundaries and invariants.
|
||||||
|
|
||||||
@@ -21,16 +21,16 @@ boundaries and invariants.
|
|||||||
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
|
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
|
||||||
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
|
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
|
||||||
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
|
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
|
||||||
| Top-level generation, batch, collection, inspection, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, persistence points, failure propagation, and orchestration invariants. |
|
| Top-level generation, batch, comparison, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md), [comparison execution internals](internal/comparison-execution.md), and [comparison publication internals](internal/comparison-publication.md) | They own workflow ordering, concurrent profile execution, output publication, failure propagation, and orchestration invariants. |
|
||||||
| Weather API transport, source envelopes, source warnings, or collection | [Weather API integration](integrations/weatherapi.md), [weather-data internals](internal/weather-data.md), and [collection internals](internal/collect.md) | These separate the external contract, normalized source facts, and app-facing collection behavior. |
|
| Weather API transport, source envelopes, source warnings, or collection | [Weather API integration](integrations/weatherapi.md), [weather-data internals](internal/weather-data.md), and [collection internals](internal/collect.md) | These separate the external contract, normalized source facts, and app-facing collection behavior. |
|
||||||
| Forecast periods, weather derivation, collected facts, or derived facts | [Forecast derivation internals](internal/forecast-derivation.md) and [fact contracts](internal/facts.md) | They own deterministic derivation and the fact boundaries used by reports. |
|
| Forecast periods, weather derivation, collected facts, or derived facts | [Forecast derivation internals](internal/forecast-derivation.md) and [fact contracts](internal/facts.md) | They own deterministic derivation and the fact boundaries used by reports. |
|
||||||
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
|
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
|
||||||
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
|
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
|
||||||
| Recent Changes comparison | [Changes internals](internal/changes.md) and [operations guide](operations.md) | The internal guide owns structured comparison; operations owns user-visible artifact behavior. |
|
| Prompt execution, profiles, prepared report inputs, or result handling | `internal/promptexec`, the Promptkit adapter, [prepared report internals](internal/prepared-report.md), and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract, immutable preparation, 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. |
|
| Durable comparison bundles or their compatibility | [Comparison bundle contract](integrations/comparison-bundle.md) and [comparison publication internals](internal/comparison-publication.md) | The integration document owns the external schema; internals own how it is published. |
|
||||||
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
|
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
|
||||||
| Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. |
|
| Output destinations, atomic publication, prompt diagnosis, or legacy cleanup | [Operations guide](operations.md), [App orchestration internals](internal/app-orchestration.md), and [comparison publication internals](internal/comparison-publication.md) | Operations owns operator workflows; internals own implementation boundaries. |
|
||||||
| Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
|
| Distributor bundles, uploads, notification results, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
|
||||||
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
|
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
|
||||||
| Release preparation, tagging, publication, or verification | [Release procedure](release.md) | It owns version selection, release-note preparation, candidate validation, tag publication, CI behavior, and post-publication checks. |
|
| 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. |
|
||||||
@@ -45,13 +45,14 @@ present before introducing a new package or abstraction.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cmd/weatherreporter` | Binary entry point. |
|
| `cmd/weatherreporter` | Binary entry point. |
|
||||||
| `internal/cli` | Command parsing, flags, help, output, and command wiring. |
|
| `internal/cli` | Command parsing, flags, help, output, and command wiring. |
|
||||||
| `internal/app` | Generation, batches, collection coordination, notification, and inspection orchestration. |
|
| `internal/app` | Stateless generation, batches, comparisons, collection coordination, output publication, and notification. |
|
||||||
|
| `internal/comparison` | Comparison identities, logical bundles, guarded destinations, and atomic bundle publication. |
|
||||||
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
|
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
|
||||||
| `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
|
| `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
|
||||||
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
|
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
|
||||||
| `internal/report`, `internal/module`, `internal/briefing`, `internal/changes` | Report registry, module contracts and values, and structured comparison. |
|
| `internal/report`, `internal/module`, `internal/briefing` | Report registry plus module and briefing contracts. |
|
||||||
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
||||||
| `internal/state`, `internal/fileutil`, `internal/timeutil` | Durable artifacts, atomic file operations, clocks, dates, timezones, and periods. |
|
| `internal/fileutil`, `internal/timeutil` | Atomic output operations, clocks, dates, timezones, and periods. |
|
||||||
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
|
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
|
||||||
| `examples` | Maintained copyable configuration. |
|
| `examples` | Maintained copyable configuration. |
|
||||||
|
|
||||||
|
|||||||
103
docs/integrations/comparison-bundle.md
Normal file
103
docs/integrations/comparison-bundle.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Comparison Bundle Contract
|
||||||
|
|
||||||
|
A comparison bundle is the durable, flat artifact produced when one report is
|
||||||
|
executed with multiple explicit Promptkit profiles. This document is the
|
||||||
|
canonical contract for consumers of those bundles. Command invocation and JSON
|
||||||
|
action summaries belong to the [CLI reference](../cli.md); destination handling
|
||||||
|
and retention belong to the [operations guide](../operations.md).
|
||||||
|
|
||||||
|
## Version And Layout
|
||||||
|
|
||||||
|
The current and only supported manifest schema version is
|
||||||
|
`weatherreporter.comparison.v1`. A bundle directory contains exactly these
|
||||||
|
regular, non-symlinked files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
comparison.json
|
||||||
|
data-package.yml
|
||||||
|
NN-profile-slug.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`comparison.json` is the manifest and `data-package.yml` is the exact YAML
|
||||||
|
input supplied to every selected profile. There is one Markdown file for each
|
||||||
|
successful result and none for failed results. `NN` is the one-based selected
|
||||||
|
profile position, zero padded to at least two digits (and widened only when
|
||||||
|
needed for 100 or more profiles). The profile slug preserves ASCII letters,
|
||||||
|
digits, `-`, and `_`; each run of other characters becomes one `-`; edge `-`
|
||||||
|
and `_` characters are removed; the value is capped at 64 bytes; and an empty
|
||||||
|
slug becomes `profile`. Logical profile IDs remain authoritative in the
|
||||||
|
manifest.
|
||||||
|
|
||||||
|
All manifest paths are basenames relative to the bundle root. They never use
|
||||||
|
path separators, `.` or `..`. The CLI reports absolute paths only after a
|
||||||
|
bundle has been published.
|
||||||
|
|
||||||
|
## Manifest Schema
|
||||||
|
|
||||||
|
The manifest is UTF-8 JSON, encoded as two-space-indented JSON with one
|
||||||
|
trailing newline. Its fields appear in this order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemaVersion, comparisonId, startedAt, finishedAt, reportId, validPeriod,
|
||||||
|
timezone, promptId, promptVersion, promptHash, dataPackage, total, succeeded,
|
||||||
|
failed, results
|
||||||
|
```
|
||||||
|
|
||||||
|
`validPeriod` contains `start` and `end`; it is a nonempty half-open period.
|
||||||
|
`dataPackage` contains `path` (always `data-package.yml`) and `sha256` (the
|
||||||
|
lowercase, 64-character SHA-256 digest of that file's exact bytes). `results`
|
||||||
|
is in the explicit profile-selection order. Its result-object fields appear in
|
||||||
|
this order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
position, profileId, backendId, modelName, status, validationStatus,
|
||||||
|
reportPath, error
|
||||||
|
```
|
||||||
|
|
||||||
|
`startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not
|
||||||
|
earlier than the former. `validPeriod` retains its resolved time offset.
|
||||||
|
`reportId`, `timezone`, prompt identity, model name, and comparison ID are
|
||||||
|
nonblank. `promptHash` and `dataPackage.sha256` are lowercase SHA-256 digests.
|
||||||
|
|
||||||
|
## Result Invariants
|
||||||
|
|
||||||
|
`total` is at least two and equals the number of results. Positions are
|
||||||
|
contiguous from one, profile IDs are distinct and nonblank, and
|
||||||
|
`succeeded + failed == total`.
|
||||||
|
|
||||||
|
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
||||||
|
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
|
||||||
|
its position, total, and logical profile ID, and no `error`. A failed result has
|
||||||
|
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
|
||||||
|
`category` and `message`. Its validation status is absent, `failed`, or
|
||||||
|
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
||||||
|
`backendId` and `validationStatus` are omitted when unavailable.
|
||||||
|
|
||||||
|
Every successful Markdown file is declared by exactly one successful result.
|
||||||
|
The directory contains no extra entries. Consumers can therefore verify the
|
||||||
|
data-package digest and the full manifest-to-file mapping without scanning a
|
||||||
|
larger workspace.
|
||||||
|
|
||||||
|
## Compatibility And Sensitivity
|
||||||
|
|
||||||
|
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
|
||||||
|
the current version, schema, file set, file types, relative-path rules, and
|
||||||
|
data-package digest. JSON field names are case-sensitive canonical names and a
|
||||||
|
field may appear only once in each manifest object. It rejects unknown,
|
||||||
|
case-variant, or duplicate fields; multiple JSON values; extra entries;
|
||||||
|
symlinks; and future or otherwise unsupported versions. Treat a bundle that
|
||||||
|
fails recognition as an ordinary directory, not as a compatible bundle.
|
||||||
|
|
||||||
|
When replacing a recognized bundle, cancellation observed before the new
|
||||||
|
bundle is installed preserves the prior bundle rather than committing the
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
Cleanup of a prior bundle occurs only after its replacement is committed and
|
||||||
|
does not affect the new bundle's compatibility. A cleanup error may identify a
|
||||||
|
complete recovery bundle, partial remnants, no remaining sibling, or an
|
||||||
|
uninspectable state; this operational state is not recorded in the manifest.
|
||||||
|
|
||||||
|
The manifest contains safe operational provenance, but `data-package.yml` and
|
||||||
|
the generated Markdown can contain sensitive weather or location context. Do
|
||||||
|
not assume these artifacts are safe for public distribution. Handle retention,
|
||||||
|
access, and deletion according to the [operations guide](../operations.md).
|
||||||
@@ -8,8 +8,10 @@ and [operations guide](../../operations.md).
|
|||||||
|
|
||||||
## Upload Admission
|
## Upload Admission
|
||||||
|
|
||||||
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
|
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
|
||||||
posts a gzip-compressed source bundle to:
|
It allows a path prefix but rejects userinfo, query strings, and fragments
|
||||||
|
before local report work begins. The client posts a gzip-compressed source
|
||||||
|
bundle to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /v1/pipelines/<pipeline_id>/upload
|
POST /v1/pipelines/<pipeline_id>/upload
|
||||||
@@ -23,11 +25,16 @@ A successful response is `202 Accepted` with JSON containing `run_id` and
|
|||||||
`status`. Acceptance means Distributor staged and validated the source bundle;
|
`status`. Acceptance means Distributor staged and validated the source bundle;
|
||||||
it does not mean downstream destinations have published it.
|
it does not mean downstream destinations have published it.
|
||||||
|
|
||||||
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
|
||||||
source-file mapping before calling Distributor. It reads the bearer token from
|
at least one source-file mapping, before calling Distributor. It reads the bearer token from
|
||||||
the configured environment variable and redacts that value from errors. Request
|
the configured environment variable and redacts that value from errors. Request
|
||||||
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
||||||
|
|
||||||
|
Weatherreporter reads at most 1 MiB from each Distributor response. An
|
||||||
|
oversized response fails notification with a stable local diagnostic. Normal
|
||||||
|
Weatherreporter results retain upload and status identity but do not repeat
|
||||||
|
Distributor response bodies, status reports, or remote error text.
|
||||||
|
|
||||||
## Idempotency
|
## Idempotency
|
||||||
|
|
||||||
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
||||||
@@ -58,8 +65,8 @@ application to record.
|
|||||||
|
|
||||||
Run and idempotency records are in-memory. Completed records expire according
|
Run and idempotency records are in-memory. Completed records expire according
|
||||||
to Distributor's `server.http.retention`, and a Distributor restart removes
|
to Distributor's `server.http.retention`, and a Distributor restart removes
|
||||||
retained status and idempotency state. Status polling decisions and persistence
|
retained status and idempotency state. Status polling decisions are internal
|
||||||
of notification artifacts are internal orchestration behavior; see the
|
orchestration behavior; see the
|
||||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||||
[application orchestration](../../internal/app-orchestration.md).
|
[application orchestration](../../internal/app-orchestration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ the upload call returns.
|
|||||||
|
|
||||||
## File Mappings
|
## File Mappings
|
||||||
|
|
||||||
Every mapping pairs a managed Markdown report source with one bundle-relative
|
Every mapping pairs an operator-owned Markdown output with one bundle-relative
|
||||||
path. A single-report notification maps its one managed report to each rendered
|
path. A single-report notification maps its published output to each rendered
|
||||||
path configured for that report. A batch notification combines mappings for
|
path configured for that report. A batch notification combines mappings for
|
||||||
every included managed report and rejects duplicate bundle paths.
|
every included published output and rejects duplicate bundle paths.
|
||||||
|
|
||||||
The report source is never an `--out` copy or an arbitrary workspace scan. The
|
The report source is the output selected for that command; the application does
|
||||||
application selects it and renders notification paths; see the [operations guide](../../operations.md)
|
not scan local directories. It renders notification paths after publication;
|
||||||
for the managed-upload rule and the [Distributor adapter](../../internal/distributor-adapter.md)
|
see the [operations guide](../../operations.md) and the
|
||||||
for the adapter boundary.
|
[Distributor adapter](../../internal/distributor-adapter.md) for the boundary.
|
||||||
|
|
||||||
Bundle paths must be clean, relative, slash-separated paths. They cannot be
|
Bundle paths must be clean, relative, slash-separated paths. They cannot be
|
||||||
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
|
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
|
|||||||
|
|
||||||
## Client And Upload
|
## Client And Upload
|
||||||
|
|
||||||
The adapter constructs the client with the configured endpoint, bearer token,
|
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
|
||||||
and an HTTP client whose timeout is the configured Distributor timeout. It
|
bearer token, and an HTTP client whose timeout is the configured Distributor
|
||||||
passes no custom retry options, so the pinned client's defaults apply: three
|
timeout. The endpoint may include a path prefix but never userinfo, a query, or
|
||||||
attempts, 100 ms base delay, and one-second maximum delay.
|
a fragment. It passes no custom retry options, so the pinned client's defaults
|
||||||
|
apply: three attempts, 100 ms base delay, and one-second maximum delay.
|
||||||
|
The adapter bounds every response to 1 MiB before handing it to the pinned
|
||||||
|
client. A response above that boundary is rejected as a local overflow rather
|
||||||
|
than decoding or retaining a prefix.
|
||||||
|
|
||||||
For each notification, Weatherreporter calls `UploadFiles` with:
|
For each notification, Weatherreporter calls `UploadFiles` with:
|
||||||
|
|
||||||
- the rendered pipeline ID;
|
- the rendered pipeline ID;
|
||||||
- the rendered bundle ID as the source manifest ID;
|
- the rendered bundle ID as the source manifest ID;
|
||||||
- the report or batch generation time as `Created`;
|
- the report or batch generation time as `Created`;
|
||||||
- the managed-report-to-bundle-path mappings described in the
|
- the published-output-to-bundle-path mappings described in the
|
||||||
[bundle mapping contract](pkg-bundle.md); and
|
[bundle mapping contract](pkg-bundle.md); and
|
||||||
- a rendered idempotency key.
|
- a rendered idempotency key.
|
||||||
|
|
||||||
@@ -38,8 +42,9 @@ adapter translates it to its own conflict error without exposing the token.
|
|||||||
The adapter then calls `Status` for the accepted run. A terminal `failed`
|
The adapter then calls `Status` for the accepted run. A terminal `failed`
|
||||||
status is a notification failure. A status lookup failure or a timeout before a
|
status is a notification failure. A status lookup failure or a timeout before a
|
||||||
terminal status remains attached to the otherwise accepted upload as diagnostic
|
terminal status remains attached to the otherwise accepted upload as diagnostic
|
||||||
status information. Polling cadence, final failure handling, redaction, and
|
status information. Normal diagnostics use local status classifications; they
|
||||||
notification artifact persistence are internal behavior documented in the
|
do not expose remote response text or the status report. Polling cadence, final
|
||||||
|
failure handling, and redaction are internal behavior documented in the
|
||||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||||
[application orchestration](../../internal/app-orchestration.md).
|
[application orchestration](../../internal/app-orchestration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,60 @@
|
|||||||
# Promptkit Integration
|
# Promptkit Integration
|
||||||
|
|
||||||
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are
|
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are `weather.daily_generated_text`, `weather.today_generated_text`, `weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version `2.0.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
|
||||||
`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
|
## Logical Profile Catalog
|
||||||
`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
|
Prompt definitions select a stable Weatherreporter profile ID. The embedded definitions currently use Promptkit's `openrouter` backend:
|
||||||
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
|
| Profile ID | Model | Reasoning effort | Timeout | Service tier | Default reports |
|
||||||
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).
|
| `weather-light` | `deepseek/deepseek-v4-flash` | Provider default | 180 seconds | `flex` | Hourly |
|
||||||
|
| `weather-balanced` | `~google/gemini-flash-latest` | `high` | 240 seconds | `flex` | Daily, Today, Tomorrow |
|
||||||
|
| `weather-deep` | `~anthropic/claude-sonnet-latest` | `high` | 240 seconds | `flex` | None |
|
||||||
|
|
||||||
|
The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded profiles intentionally omit endpoints, credentials, temperature, `top_p`, and output-token limits.
|
||||||
|
|
||||||
|
## Selection And Active Execution
|
||||||
|
|
||||||
|
Before weather collection, Weatherreporter validates the report's exact generated-text report/schema/template catalog binding, prompt version and hash, output contract, and selected profile. Active profiles must resolve a nonblank backend and model identity. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
|
||||||
|
|
||||||
|
1. explicit in-memory profiles used by an embedding consumer or test;
|
||||||
|
2. the configured `profile_file` or `profile_dir`;
|
||||||
|
3. Weatherreporter's embedded fallback profiles; and
|
||||||
|
4. Promptkit's built-in catalog.
|
||||||
|
|
||||||
|
A source falls through only when the selected ID is absent. Each source supplies a complete definition, so profile fields are not merged. A malformed matching operator definition is an error and does not fall back.
|
||||||
|
|
||||||
|
Profiles that require a direct API key are unsupported; a profile that reports `APIKeyEnv` requires a nonblank value in that environment variable. Active results retain the selected logical profile ID and resolved backend and model. Ordinary errors, summaries, logs, and outputs exclude endpoints, credentials, rendered messages, schemas, request bodies, response bodies, and complete parameter maps.
|
||||||
|
|
||||||
|
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Before accepting that JSON, Weatherreporter requires exactly one preparation callback and reconciles its prompt/profile/backend/model and rendered/input hashes with the inspected identity and completed result. The callback output contract and completed validation must use the report's expected JSON Schema mode and path. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||||
|
|
||||||
|
When capture is enabled, its preparation artifact projects a provider endpoint
|
||||||
|
to its scheme and host and retains only reviewed execution settings. Provider
|
||||||
|
extras and URL user information, paths, queries, and fragments are omitted.
|
||||||
|
Capture storage remains confined to the operator-selected debug root; an unsafe
|
||||||
|
filesystem path causes the requested execution to fail. Host availability and
|
||||||
|
operator handling are documented in the
|
||||||
|
[operations guide](../operations.md#optional-prompt-debug-capture).
|
||||||
|
|
||||||
|
## Comparison Execution
|
||||||
|
|
||||||
|
For `compare`, Weatherreporter validates the report's generated-text catalog
|
||||||
|
binding, one exact prompt, and every explicitly selected profile before weather
|
||||||
|
collection. It prepares one deterministic YAML
|
||||||
|
data package, retains immutable copies of the report inputs, and executes every
|
||||||
|
profile against the same exact data-package bytes. Each profile remains an
|
||||||
|
independent Promptkit execution: one provider, provenance, or validation failure does not
|
||||||
|
stop its peers, while caller cancellation applies to every in-flight execution.
|
||||||
|
|
||||||
|
Weatherreporter starts selected profile executions concurrently and does not
|
||||||
|
add an application-level concurrency limit. Promptkit owns backend capacity and
|
||||||
|
any profile or backend concurrency policy. A shared Weatherreporter executor
|
||||||
|
must safely accept those concurrent `Execute` calls. The durable comparison output and
|
||||||
|
its compatibility rules are defined by the
|
||||||
|
[comparison bundle contract](comparison-bundle.md); the user-facing command
|
||||||
|
contract is in the [CLI reference](../cli.md).
|
||||||
|
|
||||||
|
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Promptkit results are accepted only when their raw JSON is at most 64 KiB; the adapter drops larger results before copying them into Weatherreporter's execution values or debug artifacts. The validator also limits total generated prose to 20,000 characters, with 4,000-character summary and timing fields, a 12,000-character Hourly discussion, and at most 12 day-style paragraphs of 4,000 characters each. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
|
||||||
|
|
||||||
|
Prompt/profile configuration and the maintained local override example are owned by the [configuration reference](../config.md). Adapter construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
|
||||||
|
|||||||
@@ -9,24 +9,29 @@ are documented in [Weather data internals](../internal/weather-data.md) and
|
|||||||
|
|
||||||
## Base URL And Requests
|
## Base URL And Requests
|
||||||
|
|
||||||
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
|
`weather_api.base_url` must be an absolute HTTP(S) URL. Weatherreporter joins
|
||||||
endpoint path to the configured base URL path, so a service hosted under a path
|
each endpoint path to the configured base URL path, so a service hosted under a
|
||||||
prefix must keep that prefix available. Requests use `GET` and carry the
|
path prefix must keep that prefix available. Requests use `GET` and carry the
|
||||||
configured timeout on every HTTP attempt.
|
configured timeout on every HTTP attempt.
|
||||||
|
|
||||||
Every request sends `format` and, except where noted below, `units`. The
|
Every request sends `format` and, except where noted below, `units`. The
|
||||||
configured format must be `json`.
|
configured format must be `json`.
|
||||||
|
|
||||||
Before retrieving sources, Weatherreporter warms up
|
Before retrieving sources, Weatherreporter requests `/conditions/current` with
|
||||||
`/conditions/current` with the same `format`, `units`, and `precision` query
|
the same `format`, `units`, and `precision` query parameters used for current
|
||||||
parameters used for current conditions. The warmup only requires a readable
|
conditions. After a readable 2xx response, it retains that response for the
|
||||||
2xx response; its body is not decoded. Failure after its internal retry budget
|
normal current-conditions source step rather than making a second identical
|
||||||
stops the fetch before source requests begin.
|
request. Failure after the readiness request's internal retry budget stops the
|
||||||
|
fetch before source requests begin.
|
||||||
|
|
||||||
## Endpoints And Query Parameters
|
## Endpoints And Query Parameters
|
||||||
|
|
||||||
The adapter makes one source request for each endpoint after a successful
|
The adapter makes one source request for each endpoint, subject to retry on
|
||||||
warmup, subject to retry on transient failures.
|
transient failures. A successful readiness request supplies the current
|
||||||
|
conditions source response. The remaining independent source requests run
|
||||||
|
concurrently, then their results are processed in the source order shown below.
|
||||||
|
This keeps source provenance, missing-source policy, and surfaced errors
|
||||||
|
deterministic regardless of response order.
|
||||||
|
|
||||||
| Source | Endpoint | Query parameters | Availability |
|
| Source | Endpoint | Query parameters | Availability |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -57,14 +62,15 @@ An absent `data` member is treated as a missing source. For ordinary sources,
|
|||||||
`data: null` is also missing. The active-alert exception is listed above: its
|
`data: null` is also missing. The active-alert exception is listed above: its
|
||||||
explicit `null` payload represents an empty alert result.
|
explicit `null` payload represents an empty alert result.
|
||||||
|
|
||||||
Hourly forecast data must be present and contain at least one `period`; a
|
Hourly forecast data must be present and contain at least one `period`. Every
|
||||||
missing, malformed, or empty hourly product fails collection. The remaining
|
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
|
||||||
sources follow the configured missing-source policy. Under `error`, collection
|
after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
|
||||||
fails; under `warn`, the source is omitted and an inspectable warning is
|
product fails collection. The remaining sources follow the configured
|
||||||
recorded; under `none`, the source is omitted without a warning. A per-source
|
missing-source policy. Under `error`, collection fails; under `warn`, the
|
||||||
policy overrides the default. See [Configuration](../config.md) for policy
|
source is omitted and an inspectable warning is recorded; under `none`, the
|
||||||
settings and [Weather data internals](../internal/weather-data.md) for recorded
|
source is omitted without a warning. A per-source policy overrides the default.
|
||||||
source metadata.
|
See [Configuration](../config.md) for policy settings and [Weather data
|
||||||
|
internals](../internal/weather-data.md) for recorded source metadata.
|
||||||
|
|
||||||
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||||
Malformed `data` for an optional source follows its missing-source policy.
|
Malformed `data` for an optional source follows its missing-source policy.
|
||||||
@@ -138,9 +144,10 @@ It does not retry other HTTP statuses, malformed envelopes, missing data, or
|
|||||||
payload decoding failures. A canceled context also stops an in-progress retry
|
payload decoding failures. A canceled context also stops an in-progress retry
|
||||||
delay.
|
delay.
|
||||||
|
|
||||||
The adapter reads at most 10 MiB from one response body. A non-2xx response,
|
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
|
||||||
request construction failure, read failure, or decode failure includes endpoint
|
before decoding. A non-2xx response reports its relative endpoint and status,
|
||||||
context in its error.
|
without including upstream response text. Request construction, response-limit,
|
||||||
|
read, and decode failures include endpoint context in their errors.
|
||||||
|
|
||||||
Retry counts and delays are adapter behavior rather than Weather API request
|
Retry counts and delays are adapter behavior rather than Weather API request
|
||||||
parameters. Do not depend on a particular attempt count when implementing the
|
parameters. Do not depend on a particular attempt count when implementing the
|
||||||
|
|||||||
@@ -1,45 +1,69 @@
|
|||||||
# Application Orchestration Internals
|
# Application Orchestration Internals
|
||||||
|
|
||||||
`internal/app` owns top-level generation, batch, collection, inspection, and
|
`internal/app` owns stateless report generation, batch execution, comparison
|
||||||
notification ordering after the CLI has parsed arguments and loaded configuration.
|
orchestration, atomic output publication, and notification coordination after
|
||||||
|
`internal/cli` has parsed arguments and loaded configuration. The user contract
|
||||||
|
is owned by the [CLI reference](../cli.md) and [operations guide](../operations.md).
|
||||||
|
|
||||||
## Generation
|
## Single-Report Flow
|
||||||
|
|
||||||
`GenerateDetailed` resolves one of the four report definitions, initializes an
|
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. Output preflight validates the final filename, permits only an absent or regular final destination, and validates the bounded same-directory temporary form without creating a missing parent. It then validates the report's generated-text catalog binding, exact Promptkit prompt, and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
|
||||||
optional debug root, and inspects the exact Promptkit prompt/profile before it
|
|
||||||
collects weather or writes managed state. It then builds facts and modules,
|
|
||||||
saves the YAML data package, persists preparation metadata from the executor
|
|
||||||
callback, executes the prepared prompt, saves execution provenance and raw
|
|
||||||
output, validates generated text, renders Markdown, and optionally copies or
|
|
||||||
notifies from the managed report.
|
|
||||||
|
|
||||||
After a completed prompt run, each successfully written downstream artifact is
|
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit only against the inspected prompt and profile, reconciles the preparation callback and completed result with that identity and the prepared report schema, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` writes the completed Markdown through a same-directory temporary file, rechecks the final destination and context after close and immediately before the atomic rename. Only after that write succeeds does single-report notification run.
|
||||||
atomically added to the execution record before the corresponding metadata
|
|
||||||
rewrite. Later failures therefore leave the original Promptkit outcome and its
|
|
||||||
last durable set of reached paths inspectable.
|
|
||||||
|
|
||||||
Failure results retain all safe paths reached so far. Validation rejection
|
Failures return an active partial result with safe identity, profile, warning, validation, debug, and output information when available. After rendering and immediately before publication, the workflow checks for cancellation or deadline expiry. Any failure before publication leaves an existing destination unchanged. A notification failure retains the newly published output.
|
||||||
persists raw output and execution provenance but does not render a report.
|
|
||||||
|
|
||||||
## Batches
|
## Batches
|
||||||
|
|
||||||
`RunBatchDetailed` constructs a single debug writer and uses the request's
|
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating generated-text catalog, prompt, and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
|
||||||
single executor. Before collection it inspects Today, Tomorrow, and Daily for
|
|
||||||
morning, or Tomorrow and Daily for evening, deduplicating effective profile
|
|
||||||
inspection. It then collects once, plans eligible Daily dates, and calls the
|
|
||||||
same prompt-generation core sequentially for each planned report. Per-report
|
|
||||||
notification is suppressed; a failed report does not stop later reports.
|
|
||||||
|
|
||||||
Batch notification is skipped when disabled or when any report failed.
|
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
|
||||||
Successful notification uses the completed managed report paths only. Batch
|
|
||||||
items retain preparation, execution, and optional debug paths when reached.
|
|
||||||
|
|
||||||
## Inspection And Boundaries
|
Cancellation and deadline expiry stop the sequential loop before another report
|
||||||
|
starts. Completed report results and published paths remain successful; the
|
||||||
|
interrupted and unstarted planned reports have `canceled` status and are counted
|
||||||
|
separately from failed reports. The batch notification result records that
|
||||||
|
delivery was skipped, and the returned error retains the original context cause
|
||||||
|
for callers and CLI projection.
|
||||||
|
|
||||||
Inspection loads persisted state only. It does not collect weather, invoke
|
## Comparisons
|
||||||
Promptkit, or upload reports. The app coordinates project-owned contracts but
|
|
||||||
does not parse flags, load YAML, implement transport, construct provider SDKs,
|
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
||||||
or define report-period policy.
|
and preflights the exact bundle destination before initializing optional prompt
|
||||||
|
debugging, prompt inspection, or collection. It then validates the report's
|
||||||
|
generated-text catalog binding, inspects the one prompt and every selected
|
||||||
|
profile, collects once, and delegates shared report
|
||||||
|
construction to the prepared-report flow. It does not accept a notifier.
|
||||||
|
|
||||||
|
Once the destination is resolved, the partial result retains its absolute
|
||||||
|
output directory even when later preflight, debug initialization, inspection,
|
||||||
|
collection, or preparation fails. Every initialized result is finalized with a
|
||||||
|
finished timestamp. If prompt inspection succeeds before a later profile
|
||||||
|
inspection fails, the partial result retains the resolved prompt ID, version,
|
||||||
|
and hash. Artifact paths are added only after publication commits.
|
||||||
|
|
||||||
|
The comparison execution core starts each inspected profile independently,
|
||||||
|
keeps results in selection order, and waits for all started work. Every profile
|
||||||
|
reconciles its callback and completion provenance before its JSON can be
|
||||||
|
rendered. Independent profile failures are recorded and do not stop peers; a
|
||||||
|
completed profile failure remains recorded if cancellation happens later.
|
||||||
|
Context cancellation marks only unfinished or cancellation-terminated work and
|
||||||
|
prevents publication. Details of
|
||||||
|
prepared values, execution and debugging, and publication are documented in [prepared report
|
||||||
|
internals](prepared-report.md), [comparison execution
|
||||||
|
internals](comparison-execution.md), and [comparison publication
|
||||||
|
internals](comparison-publication.md).
|
||||||
|
|
||||||
|
When publication has committed its new bundle, application results contain the
|
||||||
|
absolute manifest, data-package, and successful report paths even if removal of
|
||||||
|
the previous sibling backup then fails. That cleanup failure is still returned
|
||||||
|
as an operational error rather than treating the new bundle as unpublished;
|
||||||
|
the returned error identifies the observed recovery state and includes a path
|
||||||
|
only when cleanup left a sibling behind.
|
||||||
|
|
||||||
|
## Boundaries And Verification
|
||||||
|
|
||||||
|
The package does not parse flags, load YAML, implement transport, construct provider SDKs, or define report-period policy. Prompt, profile, weather, and Distributor implementations remain behind project-owned contracts.
|
||||||
|
|
||||||
Focused checks:
|
Focused checks:
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ snapshot, construct YAML, invoke Promptkit, or render a report.
|
|||||||
|
|
||||||
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||||
required collected and derived facts, supported report IDs, missing-data
|
required collected and derived facts, supported report IDs, missing-data
|
||||||
behavior, duplicate policy, builder, and optional prompt exporter.
|
behavior, duplicate policy, builder, and optional prompt exporter. The
|
||||||
|
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
|
||||||
|
identity, category, and availability predicate; registry construction rejects
|
||||||
|
unknown requirements and requirements listed under the wrong category.
|
||||||
|
|
||||||
`BuildModule` first verifies the requested module, report compatibility, and
|
`BuildModule` first verifies the requested module, report compatibility, and
|
||||||
option shape. It then applies the declared missing-data behavior:
|
option shape. It then applies the declared missing-data behavior:
|
||||||
@@ -32,29 +35,60 @@ discussion, and weather story. Derived builders shape daily and daypart
|
|||||||
summaries, precipitation timing, outdoor windows, and the report-specific
|
summaries, precipitation timing, outdoor windows, and the report-specific
|
||||||
Daily, Today, and Tomorrow planning values.
|
Daily, Today, and Tomorrow planning values.
|
||||||
|
|
||||||
|
The daily summary preserves generic feels-like values as
|
||||||
|
`apparent_temperature_max_f`; it does not label them as a heat index. Daypart
|
||||||
|
temperature phrases retain below-zero meaning, including through temperature
|
||||||
|
trends that cross zero. Outdoor windows add a 25-point risk penalty and an
|
||||||
|
explicit reason for each snow, ice, or fog indicator. Equal scores retain input
|
||||||
|
order for both best and worst windows.
|
||||||
|
|
||||||
The module registry preserves rich values for templates and snapshots while
|
The module registry preserves rich values for templates and snapshots while
|
||||||
curating prompt exports where needed. In particular, source warnings are a
|
curating prompt exports where needed. In particular, source warnings are a
|
||||||
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||||
missing sources, and prompt-safe SPC values omit geometry and other
|
missing sources, and prompt-safe SPC values omit geometry and other
|
||||||
template-only or source details. The complete module composition is in
|
template-only or source details. The complete module composition is in
|
||||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||||
|
Alert digests are built from selected alert items and source provenance, not a
|
||||||
|
provider response envelope.
|
||||||
|
|
||||||
`area_forecast_discussion` accepts an optional typed section filter. Planning
|
Derived daypart-summary maps use the forecast package's canonical daypart
|
||||||
modules are report-specific: `daily_planning` supports Daily,
|
identity and reject any collision instead of replacing an earlier value.
|
||||||
|
Planning applies the same identity when recognizing morning, afternoon,
|
||||||
|
evening, and overnight windows; display labels remain separate and preserve
|
||||||
|
configured text with rune-safe first-letter capitalization.
|
||||||
|
|
||||||
|
The embedded SPC background-definition asset records its authoritative sources,
|
||||||
|
source update dates, and maintainer review schedule. Its categorical
|
||||||
|
`official_description` values transcribe the [SPC convective-outlook risk
|
||||||
|
table](https://www.spc.noaa.gov/about/outlooks/); its Conditional Intensity
|
||||||
|
Group entries follow the [SPC conditional-intensity
|
||||||
|
reference](https://www.spc.noaa.gov/exper/conditional-intensity-information).
|
||||||
|
`plain_language` values are Weatherreporter summaries. Weatherreporter
|
||||||
|
maintainers review the asset annually and whenever either source changes.
|
||||||
|
|
||||||
|
`area_forecast_discussion` accepts an optional typed section filter. Accepted
|
||||||
|
typed option pointers are normalized to the declared value type before builder
|
||||||
|
execution. Planning modules are report-specific: `daily_planning` supports Daily,
|
||||||
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||||
|
|
||||||
## Missing data and boundaries
|
## Missing data and boundaries
|
||||||
|
|
||||||
Optional current conditions, narrative products, discussions, and weather
|
Optional current conditions, narrative products, discussions, and weather
|
||||||
stories may be omitted. Required derived modules fail when their declared facts
|
stories may be omitted. A weather story is usable only when it has non-blank
|
||||||
|
displayable content (title, description, alternate text, or download URL) or a
|
||||||
|
valid start/end period; otherwise collection applies its optional-source policy
|
||||||
|
and the module is omitted. Required derived modules fail when their declared facts
|
||||||
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||||
the package's severity criterion and matching discussion text exists.
|
the package's severity criterion and matching discussion text exists.
|
||||||
|
|
||||||
Effective units, timezone, and location context arrive in `ModuleContext` from
|
`ModuleContext` carries the effective units, timezone, location context, and
|
||||||
configuration and resolved report metadata. Field defaults are owned by
|
prepared identity. Report preparation creates that one `PreparedIdentity` for
|
||||||
[configuration](../config.md), and prompt-package layout is owned by
|
the shared report identity, timing, configuration context, and source warnings
|
||||||
[prompt input](prompt-input.md).
|
before module construction. The metadata module projects its matching fields
|
||||||
|
from that value and retains its prompt-safe shape. Field defaults are owned by
|
||||||
|
[configuration](../config.md), and prompt-package layout is owned by [prompt
|
||||||
|
input](prompt-input.md).
|
||||||
|
|
||||||
## Verification and invariants
|
## Verification and invariants
|
||||||
|
|
||||||
@@ -66,4 +100,4 @@ go test ./internal/briefing
|
|||||||
```
|
```
|
||||||
|
|
||||||
Builders emit structured facts, never report prose. The app collects their
|
Builders emit structured facts, never report prose. The app collects their
|
||||||
outputs into a module snapshot, and state persists that snapshot.
|
outputs into an in-memory module snapshot for prompt input and rendering.
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Changes Internals
|
|
||||||
|
|
||||||
`internal/changes` deterministically compares a compatible prior module
|
|
||||||
snapshot with the current snapshot. It returns compact structured changes for
|
|
||||||
prompt input; it never reads state, finds a prior report, renders Markdown, or
|
|
||||||
compares generated text. Snapshot construction belongs to
|
|
||||||
[module internals](module.md), and prior-snapshot discovery belongs to
|
|
||||||
[state internals](state.md).
|
|
||||||
|
|
||||||
## Comparison inputs and output
|
|
||||||
|
|
||||||
Each comparator receives a prior snapshot, a current snapshot, and
|
|
||||||
`Thresholds`. A `Change` has a stable type and message plus previous and
|
|
||||||
current values where useful. Changes are sorted by type and then message, so
|
|
||||||
the same inputs always yield the same order.
|
|
||||||
|
|
||||||
Threshold values are supplied by application orchestration from the
|
|
||||||
[Recent Changes configuration](../config.md#recent_change); this package does
|
|
||||||
not load configuration or choose defaults. Numeric changes are emitted when
|
|
||||||
the absolute difference meets the configured threshold. Precipitation also
|
|
||||||
requires a change between its low, possible, likely, and high categories.
|
|
||||||
|
|
||||||
## Strategies
|
|
||||||
|
|
||||||
| Comparator | Required snapshot data | Compared values |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `CompareDaily` | `derived_daily_summary`, `derived_daypart_summaries` | Low and high temperature, daily precipitation probability and timing, peak gust, alerts, and aggregate indicators |
|
|
||||||
|
|
||||||
For daily comparison, `alert_digest` and `precip_timing` are optional: alerts
|
|
||||||
are compared when present, and timing is compared only when both snapshots
|
|
||||||
contain it.
|
|
||||||
|
|
||||||
The application selects a comparator only after state lookup establishes a
|
|
||||||
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator.
|
|
||||||
Hourly reports do not produce a Recent Changes list.
|
|
||||||
|
|
||||||
## Missing data and failures
|
|
||||||
|
|
||||||
Required stanzas that are absent or cannot be decoded return an error with the
|
|
||||||
snapshot and stanza context. Optional stanzas may be absent. A snapshot with no
|
|
||||||
eligible predecessor is not a comparison failure: the caller supplies an empty
|
|
||||||
change list without invoking this package.
|
|
||||||
|
|
||||||
The package has no filesystem, transport, CLI, renderer, or persistence
|
|
||||||
behavior. It does not decide report compatibility or retain snapshots.
|
|
||||||
|
|
||||||
## Verification and invariants
|
|
||||||
|
|
||||||
Focused tests cover the daily strategy, threshold boundaries, indicator and
|
|
||||||
alert changes, and missing required stanzas:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/changes
|
|
||||||
```
|
|
||||||
|
|
||||||
Recent Changes always compare structured snapshot values, never report prose.
|
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
# CLI Internals
|
# CLI Internals
|
||||||
|
|
||||||
`internal/cli` parses terminal arguments, loads configuration, constructs app
|
`internal/cli` parses terminal arguments, loads configuration, constructs app requests, and translates app results to bounded JSON summaries. The public contract belongs in the [CLI reference](../cli.md).
|
||||||
requests, and translates app results to bounded JSON summaries. The user
|
|
||||||
contract belongs in the [CLI reference](../cli.md).
|
|
||||||
|
|
||||||
The root `--version` flag reports the build version supplied by
|
The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
|
||||||
`internal/buildinfo`. Tagged release builds replace its development default at
|
|
||||||
link time.
|
|
||||||
|
|
||||||
For each `generate` or `run` action, `Runner` constructs one project-owned
|
The executable derives its action context from `SIGINT` and `SIGTERM` and
|
||||||
Promptkit executor after configuration loads. It passes the executor and any
|
passes it to `Runner.Run`. Signal cancellation therefore uses the same action,
|
||||||
`--llm-debug-dir` request into the app. `run` accepts the debug flag as well
|
summary, and error paths as other context cancellation.
|
||||||
as `generate`; the app, not the CLI, secures and initializes the debug root.
|
|
||||||
|
|
||||||
Summaries include identity, status, safe artifact paths, and notification
|
For each `generate`, `run`, or `compare` action, `Runner` constructs one project-owned Promptkit executor after request preflight and configuration loading. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
|
||||||
provenance. They intentionally exclude module values, YAML package bodies, raw
|
|
||||||
generated text, rendered prompts, schemas, endpoints, credentials, and full
|
|
||||||
Distributor payloads. A failed action with a partial result still emits its
|
|
||||||
safe summary before its error is returned.
|
|
||||||
|
|
||||||
CLI code owns no report policy, weather collection, persistence, provider
|
The CLI dispatches generation, batch, and comparison actions. It has no persisted-run or inspection dispatch. Generation and batch summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. Comparison summaries retain their ordered profile results and published bundle paths when available. All summaries intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned unless `--quiet` is set.
|
||||||
execution, or notification policy. Focused checks:
|
|
||||||
|
CLI code owns report-date flag acceptance and date resolution, but not report
|
||||||
|
composition, weather collection, output publication, provider execution, or
|
||||||
|
notification policy. Focused checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/cli
|
go test ./internal/cli
|
||||||
|
|||||||
@@ -1,43 +1,36 @@
|
|||||||
# Collection Internals
|
# Collection Internals
|
||||||
|
|
||||||
`internal/collect` is the application-facing boundary for collecting the
|
`internal/collect` is the small application-facing boundary that obtains one
|
||||||
normalized Weather API bundle. The external HTTP contract belongs in the
|
normalized Weather API bundle. The external HTTP contract belongs in the
|
||||||
[Weather API integration guide](../integrations/weatherapi.md); normalized data
|
[Weather API integration guide](../integrations/weatherapi.md); normalized
|
||||||
semantics belong in [weather-data internals](weather-data.md).
|
source values belong in [weather-data internals](weather-data.md).
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
`Run` accepts a `context.Context` and a `Request` containing effective
|
`Run` receives a context and effective configuration in `Request`. It creates
|
||||||
`config.Config`. It constructs the Weather API adapter from that configuration,
|
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
|
||||||
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
|
normalized bundle in `Result`. Adapter construction errors are wrapped as
|
||||||
|
weather-collection setup errors and fetch errors as bundle-collection errors.
|
||||||
|
|
||||||
The package wraps adapter construction failures as weather-collection setup
|
The package neither chooses reports nor derives facts, builds modules, invokes
|
||||||
errors and fetch failures as bundle-collection errors. It does not retry,
|
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
|
||||||
persist, select reports, derive facts, build modules, invoke Promptkit, or
|
retrieval, response limits, and source-level warnings belong to the Weather
|
||||||
notify Distributor.
|
API adapter and its integration contract.
|
||||||
|
|
||||||
## Application Composition
|
## Application Use
|
||||||
|
|
||||||
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
`internal/app` owns the `Collector` interface used by report workflows and
|
||||||
the production implementation delegates to `collect.Run`. Generation, batch
|
tests. Its default implementation delegates to `collect.Run`; callers may
|
||||||
execution, and explicit bundle fetching all use this boundary. Application
|
substitute a collector at that boundary. Application orchestration owns
|
||||||
orchestration rejects a nil collector result or a nil bundle before report work
|
collection timing, reuse across a workflow, and the handling of nil collection
|
||||||
can continue.
|
results. See [app orchestration internals](app-orchestration.md) for that
|
||||||
|
flow.
|
||||||
|
|
||||||
Single-report generation and a batch each collect once. A batch passes the same
|
## Verification
|
||||||
normalized collection to planning and to every report it generates. Collection
|
|
||||||
failure prevents later workflow work for that request.
|
|
||||||
|
|
||||||
## Boundaries And Invariants
|
Focused package tests cover a successful fetch and wrapping failures from
|
||||||
|
adapter construction and bundle retrieval:
|
||||||
|
|
||||||
Collection owns adapter creation and retrieval of one normalized bundle. It
|
```sh
|
||||||
must not make report, period, batch, prompt, module, filesystem, or notification
|
go test ./internal/collect
|
||||||
decisions.
|
```
|
||||||
|
|
||||||
- App-facing Weather API collection always passes through this package.
|
|
||||||
- The returned value is normalized source data, not facts or prompt input.
|
|
||||||
- Context cancellation is passed to the Weather API adapter.
|
|
||||||
- Errors retain whether setup or fetching failed.
|
|
||||||
|
|
||||||
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
|
|
||||||
also covered by `internal/app/app_test.go`.
|
|
||||||
|
|||||||
32
docs/internal/comparison-execution.md
Normal file
32
docs/internal/comparison-execution.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Comparison Execution Internals
|
||||||
|
|
||||||
|
The comparison execution core receives an already prepared report and an
|
||||||
|
already inspected, ordered profile list. It initializes an outcome for every
|
||||||
|
selected profile, launches each started profile in its own goroutine, and
|
||||||
|
waits for every started goroutine before returning. Results retain the supplied
|
||||||
|
selection order even though execution completes in an arbitrary order.
|
||||||
|
|
||||||
|
Every profile uses the exact inspected prompt identity and a private copy of
|
||||||
|
the same prepared data package. Provider, generated-text validation, rendering,
|
||||||
|
or debug-write failure becomes that profile's safe failed outcome and does not
|
||||||
|
cancel its peers. The shared executor must support those concurrent `Execute`
|
||||||
|
calls. The application deliberately imposes no additional semaphore: Promptkit
|
||||||
|
owns backend capacity. A profile failure completed before a later cancellation
|
||||||
|
remains its original safe outcome; cancellation or a deadline marks only
|
||||||
|
unfinished or cancellation-terminated outcomes as skipped or failed, joins work,
|
||||||
|
and prevents bundle publication.
|
||||||
|
|
||||||
|
When debugging is enabled, each execution receives a deterministic reference
|
||||||
|
derived from the comparison identity, ordered profile position, and safe
|
||||||
|
profile slug. This keeps concurrent captures separate. The debug writer itself
|
||||||
|
owns secure-root validation and file permissions. It safely creates shared
|
||||||
|
missing ancestors during concurrent writes, then rejects symlink and non-
|
||||||
|
directory components. Operational retention and sensitivity are documented in
|
||||||
|
the [operations guide](../operations.md). This secure writer is enabled only on
|
||||||
|
Unix hosts; comparison fails before execution when another host requests debug
|
||||||
|
capture.
|
||||||
|
|
||||||
|
The output result and its safe errors are converted into the durable contract
|
||||||
|
only by comparison publication. See [comparison publication
|
||||||
|
internals](comparison-publication.md) and the external [comparison bundle
|
||||||
|
contract](../integrations/comparison-bundle.md).
|
||||||
47
docs/internal/comparison-publication.md
Normal file
47
docs/internal/comparison-publication.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Comparison Publication Internals
|
||||||
|
|
||||||
|
`internal/comparison` separates the logical bundle from filesystem mechanics.
|
||||||
|
The application builds a validated manifest, exact shared data-package bytes,
|
||||||
|
and only the Markdown files for successful profiles. The durable layout,
|
||||||
|
schema, and compatibility rules are owned by the [comparison bundle
|
||||||
|
contract](../integrations/comparison-bundle.md).
|
||||||
|
|
||||||
|
Recognition first token-validates the manifest's object fields, rejecting
|
||||||
|
unknown, case-variant, and duplicate names before decoding its typed schema.
|
||||||
|
Manifest validation derives each successful report filename from its ordered
|
||||||
|
position, total profile count, and logical profile ID; logical-bundle and
|
||||||
|
filesystem validation then require that exact path and file set.
|
||||||
|
|
||||||
|
Destination planning is read-only. It requires an exact absolute target that
|
||||||
|
is neither the filesystem root nor the working directory, rejects unsafe
|
||||||
|
symlinks and non-directories, accepts a missing or empty directory, and permits
|
||||||
|
replacement only for a recognized current bundle. Publication rechecks the
|
||||||
|
destination namespace and type immediately before it writes a private sibling
|
||||||
|
staging directory. For replacement, it moves the prior bundle to a private
|
||||||
|
sibling backup, fully reauthorizes that moved entry, checks for cancellation,
|
||||||
|
and restores it if cancellation or installing the new bundle prevents
|
||||||
|
replacement. If guarded restoration fails, the error retains the prior bundle's
|
||||||
|
recovery path.
|
||||||
|
|
||||||
|
Planning also validates the final component and the bounded fixed names used
|
||||||
|
for private staging and backup siblings. A destination that cannot form those
|
||||||
|
names is rejected before publication creates a missing parent directory; a
|
||||||
|
maximum-length valid destination remains usable because transaction siblings do
|
||||||
|
not incorporate its basename.
|
||||||
|
|
||||||
|
The new bundle is committed only after the staged directory has been installed
|
||||||
|
at the target. From that point its artifact paths are authoritative: a failure
|
||||||
|
to remove the retained sibling backup does not roll back the new bundle.
|
||||||
|
After a cleanup failure, publication inspects the sibling without masking the
|
||||||
|
original filesystem cause. Its inspectable cleanup result distinguishes a
|
||||||
|
complete recognized recovery bundle, partial remnants, an absent sibling, or
|
||||||
|
an uninspectable state. A recovery path is reported only when something
|
||||||
|
remains; only a complete recognized bundle is suitable for rollback recovery.
|
||||||
|
|
||||||
|
The application preflights before prompt inspection and collection. Publication
|
||||||
|
performs its transaction-boundary checks and final moved-destination
|
||||||
|
authorization before installation. A cancellation or any failure before the
|
||||||
|
commit leaves the prior destination untouched. Completed bundles include
|
||||||
|
partial profile results; comparison publication never coordinates Distributor
|
||||||
|
notification. Operator-facing lifecycle and cleanup are in the
|
||||||
|
[operations guide](../operations.md).
|
||||||
@@ -13,7 +13,9 @@ the token, an optional timeout, and an injectable upstream-client factory.
|
|||||||
`New` validates its configuration before creating the adapter. For each upload,
|
`New` validates its configuration before creating the adapter. For each upload,
|
||||||
the adapter reads the token from the configured environment variable and builds
|
the adapter reads the token from the configured environment variable and builds
|
||||||
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
||||||
matches the local positive timeout.
|
matches the local positive timeout. Its transport reads at most 1 MiB from any
|
||||||
|
Distributor response before the pinned client decodes it; an oversized response
|
||||||
|
is a distinct local failure and does not trigger an extra upload attempt.
|
||||||
|
|
||||||
The upstream client is an implementation dependency, not a source of
|
The upstream client is an implementation dependency, not a source of
|
||||||
application configuration: retry ownership, pipeline selection, path
|
application configuration: retry ownership, pipeline selection, path
|
||||||
@@ -43,20 +45,26 @@ persist notification artifacts.
|
|||||||
An accepted upload is followed by one status request. When a timeout is
|
An accepted upload is followed by one status request. When a timeout is
|
||||||
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||||
until the context ends. The translated `UploadResult` contains the run ID,
|
until the context ends. The translated `UploadResult` contains the run ID,
|
||||||
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
status, and `RunStatus`, including pipeline ID and lifecycle timestamps.
|
||||||
and remote error details.
|
Remote response bodies, status reports, and remote error text are not retained
|
||||||
|
in normal results. HTTP failures retain a local typed status-code and
|
||||||
|
retryability classification; conflicts retain the local idempotency-conflict
|
||||||
|
type.
|
||||||
|
|
||||||
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||||
the caller can record an accepted-but-unconfirmed delivery. A terminal failed
|
the caller can report an accepted-but-unconfirmed delivery, using a bounded
|
||||||
run returns that result and an error. Upload failures return no result. Upstream
|
repository-owned diagnostic rather than remote text. A terminal failed run
|
||||||
|
returns that result and an error. Upload failures return no result. Upstream
|
||||||
idempotency conflicts become the local `IdempotencyConflictError`, which adds
|
idempotency conflicts become the local `IdempotencyConflictError`, which adds
|
||||||
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
||||||
the token.
|
the token.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Focused tests cover configuration validation, request mapping, timeouts and
|
Focused tests cover configuration validation, request mapping, response size
|
||||||
polling, status translation, conflict handling, and token redaction:
|
boundaries, safe diagnostics, timeouts and polling, status translation, conflict
|
||||||
|
handling, and token redaction. A local HTTP server exercises the production
|
||||||
|
upload and status boundary:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/adapters/distributor
|
go test ./internal/adapters/distributor
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ story, and convective outlook data. Source provenance and warnings are copied
|
|||||||
into their own slices so downstream consumers can inspect data completeness
|
into their own slices so downstream consumers can inspect data completeness
|
||||||
without treating it as an ordinary weather fact.
|
without treating it as an ordinary weather fact.
|
||||||
|
|
||||||
|
Alert facts retain individual alert payloads for period selection together with
|
||||||
|
their copied source provenance; they do not retain a provider response envelope.
|
||||||
|
|
||||||
A nil bundle produces an empty collected value. Collection itself, missing
|
A nil bundle produces an empty collected value. Collection itself, missing
|
||||||
source policy, and source hashes are outside this package.
|
source policy, and source hashes are outside this package.
|
||||||
|
|
||||||
@@ -26,6 +29,8 @@ uses half-open period overlap to select hourly, narrative, daily, and alert
|
|||||||
data; it also derives precipitation timing. Convective outlooks are retained
|
data; it also derives precipitation timing. Convective outlooks are retained
|
||||||
only when their valid interval overlaps the report period, with discussions
|
only when their valid interval overlaps the report period, with discussions
|
||||||
kept for represented outlook days. Both collections are sorted deterministically.
|
kept for represented outlook days. Both collections are sorted deterministically.
|
||||||
|
It rejects collected hourly data with a precipitation probability outside the
|
||||||
|
finite 0 through 100 percentage domain before constructing derived facts.
|
||||||
|
|
||||||
Report identity controls the summary shape:
|
Report identity controls the summary shape:
|
||||||
|
|
||||||
@@ -37,6 +42,8 @@ Report identity controls the summary shape:
|
|||||||
`DaypartSummaries` is collected from the resulting daily 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).
|
||||||
|
Daily alert overlaps remain scoped to the civil day, while overnight daypart
|
||||||
|
summaries retain alerts that overlap their complete next-day window.
|
||||||
|
|
||||||
## Missing data and failures
|
## Missing data and failures
|
||||||
|
|
||||||
@@ -60,5 +67,5 @@ go test ./internal/facts
|
|||||||
```
|
```
|
||||||
|
|
||||||
Facts are derived once for a resolved report from already collected data.
|
Facts are derived once for a resolved report from already collected data.
|
||||||
They remain reusable structured values: prompt wording, state persistence,
|
They remain reusable structured values for prompt input and template
|
||||||
prior-report comparison, and template presentation are owned elsewhere.
|
presentation, which are owned elsewhere.
|
||||||
|
|||||||
@@ -2,62 +2,46 @@
|
|||||||
|
|
||||||
`internal/forecast` deterministically selects and summarizes normalized
|
`internal/forecast` deterministically selects and summarizes normalized
|
||||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||||
registry dependency. Its summaries are consumed by
|
registry dependency. The report-scoped caller is [fact
|
||||||
[fact contracts](facts.md) and later module builders.
|
contracts](facts.md), which owns the choice of data required by each report.
|
||||||
|
|
||||||
## Period and daypart semantics
|
## Daily Derivation
|
||||||
|
|
||||||
Selections use `timeutil.Period` half-open overlap: a value is selected only
|
`BuildDailySummary` builds one summary for one local civil day. The facts
|
||||||
when both intervals share time. `BuildDailySummary` creates one local civil
|
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
|
||||||
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
|
||||||
requested period, preserving partial first and last days.
|
the shared half-open overlap rule used while selecting source values.
|
||||||
|
|
||||||
`ResolveDayparts` converts each configured name, start clock, and end clock
|
`ResolveDayparts` turns configured local clock ranges into windows. A range
|
||||||
into a local window. An end clock at or before its start clock wraps into the
|
whose end is not after its start continues into the next civil day. The
|
||||||
next civil day. The daypart and timezone defaults are defined in the
|
available daypart and timezone settings are defined in the
|
||||||
[configuration reference](../config.md), not here.
|
[configuration reference](../config.md).
|
||||||
|
|
||||||
## Deterministic summaries
|
The summary keeps selected hourly and narrative values, the discussion,
|
||||||
|
source warnings and provenance, alert overlaps, and one summary for each
|
||||||
|
resolved daypart. Daypart summaries derive their measurements, conditions,
|
||||||
|
weather indicators, and precipitation timing from normalized forecast
|
||||||
|
periods. `BuildPrecipTiming` is also available to the facts layer for a
|
||||||
|
report's selected hourly periods.
|
||||||
|
|
||||||
`BuildDailySummary` requires an hourly run with at least one period. It adds
|
## Boundaries And Failures
|
||||||
the selected narrative periods, discussion, alert overlaps, source provenance,
|
|
||||||
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
|
|
||||||
its selected hourly periods and derives temperature and apparent-temperature
|
|
||||||
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
|
||||||
and weather indicators.
|
|
||||||
|
|
||||||
Indicators are deterministic checks over normalized values and condition text:
|
Daily-summary construction requires a bundle with hourly forecast data,
|
||||||
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
valid precipitation probabilities, and valid daypart definitions. Optional
|
||||||
wind text are detected from the forecast description. `BuildPrecipTiming`
|
normalized products remain absent when unavailable. Invalid alerts are ignored
|
||||||
sorts periods, records the maximum and first precipitation, groups contiguous
|
while valid overlaps are selected for the relevant day or daypart window.
|
||||||
periods at or above its package-owned probability threshold, and records
|
|
||||||
thunder mentions.
|
|
||||||
|
|
||||||
Alert overlap parsing supports the normalized alert payload's available timing
|
Thresholds, text classification, unit normalization, and alert selection are
|
||||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
package implementation rules. Report identity, period selection, and the
|
||||||
are clipped to the requested period and ordered by alert start time.
|
resulting derived-fact shape are owned by [fact contracts](facts.md); external
|
||||||
|
source semantics are owned by [weather-data internals](weather-data.md).
|
||||||
|
|
||||||
## Missing data and failures
|
## Verification
|
||||||
|
|
||||||
Empty selections yield empty summary fields rather than generated prose.
|
Focused `internal/forecast` tests exercise daily and overnight dayparts,
|
||||||
Direct daily or period-summary calls fail when their required bundle, valid
|
summary derivation, invalid precipitation data, precipitation timing, and
|
||||||
period, hourly data, or daypart definitions are invalid. A nil location uses
|
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
|
||||||
UTC when these APIs are called directly. Optional narrative, discussion, and
|
|
||||||
alerts remain absent when their normalized products are absent.
|
|
||||||
|
|
||||||
Forecast thresholds used for brief indicators and precipitation timing are
|
|
||||||
implementation rules. User-configurable Recent Changes thresholds are applied
|
|
||||||
by [changes internals](changes.md), whose defaults are documented in
|
|
||||||
[configuration](../config.md).
|
|
||||||
|
|
||||||
## Verification and invariants
|
|
||||||
|
|
||||||
Focused tests cover local civil days, clipped periods, daypart resolution,
|
|
||||||
summary metrics, precipitation windows, threshold helpers, and alert overlap:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/forecast ./internal/timeutil
|
go test ./internal/forecast ./internal/facts
|
||||||
```
|
```
|
||||||
|
|
||||||
The package preserves normalized inputs as inspectable structured values and
|
|
||||||
never decides report identity, delivery, or presentation wording.
|
|
||||||
|
|||||||
@@ -9,32 +9,55 @@ maintainer-facing context fields belong to [report templates](../templates.md).
|
|||||||
## Catalog and validation
|
## Catalog and validation
|
||||||
|
|
||||||
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||||
generated text. `LookupDefinition` rejects unknown schema or template IDs and
|
generated text. `LookupDefinition` requires the exact report, schema, and
|
||||||
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
|
template triple and rejects unknown IDs, unsupported pairs, and a pair that
|
||||||
value and canonical normalized JSON, loads its canonical schema through
|
belongs to another report before the run begins. A handler validates and
|
||||||
|
normalizes raw JSON into a typed value, loads its canonical schema through
|
||||||
`internal/promptassets`, builds a render context, and renders through
|
`internal/promptassets`, builds a render context, and renders through
|
||||||
`internal/reporttemplate`.
|
`internal/reporttemplate`.
|
||||||
|
|
||||||
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
|
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
|
||||||
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
||||||
and a single trimmed discussion string. Each form permits optional trimmed
|
and a single trimmed discussion string. Every form also requires the
|
||||||
precipitation-timing and confidence prose. Typed decoding rejects unknown JSON
|
`precipitation_timing` field; an empty string means there is no supported timing
|
||||||
fields; no general-purpose JSON Schema engine is used at runtime.
|
prose to render. Typed decoding requires the exact lowercase JSON field names,
|
||||||
|
rejects missing, duplicate, case-variant, and unknown fields, and checks field
|
||||||
|
shapes; no general-purpose JSON Schema engine is used at runtime.
|
||||||
|
|
||||||
|
The validator accepts at most 64 KiB of raw JSON before it allocates typed
|
||||||
|
values. Its JSON Schemas and typed checks limit `summary` and
|
||||||
|
`precipitation_timing` to 4,000 characters each. Hourly
|
||||||
|
`forecast_discussion` is limited to 12,000 characters. Day-style discussion
|
||||||
|
accepts at most 12 paragraphs of at most 4,000 characters each. Across all
|
||||||
|
prose fields, one report may contain at most 20,000 characters. These bounds
|
||||||
|
apply before trimming, filtering, normalization, and template rendering.
|
||||||
|
|
||||||
|
Malformed JSON and field values return short, content-safe errors. They name
|
||||||
|
only canonical fields where useful and never echo provider values or unknown
|
||||||
|
field names. The Promptkit adapter also drops an oversized provider result
|
||||||
|
before copying it into execution or debug state; direct executor implementations
|
||||||
|
receive the same enforcement in this package.
|
||||||
|
|
||||||
## Render contexts
|
## Render contexts
|
||||||
|
|
||||||
The catalog's report-specific builders receive briefing metadata, a rich module
|
The catalog's report-specific builders receive the prepared report identity, a
|
||||||
snapshot, collected facts, derived facts, and the matching validated generated
|
rich module snapshot, derived facts needed to order dayparts, and the matching
|
||||||
text. They decode the module stanzas needed by the template and build typed
|
validated generated text. They require the identity's report ID to match the
|
||||||
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
selected builder. When the optional metadata stanza is present, every shared
|
||||||
metadata and periods, preserves rich module values, and uses ordered slices for
|
identity field must agree with that prepared authority before context
|
||||||
template iteration rather than maps.
|
construction continues. Builders then decode the module stanzas needed by the
|
||||||
|
template and build typed Daily, Today, Tomorrow, or Hourly contexts. Contexts
|
||||||
|
expose only display-ready report values, generated prose, and module values;
|
||||||
|
they do not expose complete collected or derived fact bundles. Ordered slices
|
||||||
|
remain the template iteration surface rather than maps.
|
||||||
|
|
||||||
Optional source stanzas become nil or fallback context fields. Missing required
|
Optional source stanzas become nil or fallback context fields. Today also
|
||||||
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
computes whether its ordered dayparts contain a displayable condition so the
|
||||||
that does not match the chosen handler fail before template execution. Prompt
|
template can render either rows or its explicit no-details fallback. Missing
|
||||||
packages, raw Promptkit output, state persistence, and template asset lookup
|
required stanzas, type-decoding failures, conflicting identity values, invalid
|
||||||
remain outside this package.
|
metadata, or a generated-text type that does not match the chosen handler fail
|
||||||
|
before template execution. Prompt packages, raw Promptkit output handling, and
|
||||||
|
template asset lookup remain outside this package.
|
||||||
|
|
||||||
## Verification and invariants
|
## Verification and invariants
|
||||||
|
|
||||||
@@ -47,5 +70,7 @@ 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 report definition must resolve to exactly one
|
module and fact values. The renderer applies its plain-text policy to every
|
||||||
supported catalog pair.
|
generated prose insertion, preserving ordinary text and paragraph breaks while
|
||||||
|
preventing provider text from creating Markdown or HTML structure. Every report
|
||||||
|
definition must resolve to exactly one supported catalog pair.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Module Contract Internals
|
# Module Contract Internals
|
||||||
|
|
||||||
`internal/module` defines the stable envelope between report composition,
|
`internal/module` defines the envelope between report composition,
|
||||||
module builders, snapshots, comparisons, templates, and prompt packages. It
|
module builders, in-memory snapshots, templates, and prompt packages. It
|
||||||
does not define a report, execute a builder, or choose prompt-export policy;
|
does not define a report, execute a builder, or choose prompt-export policy;
|
||||||
those responsibilities belong to [report registry](report-registry.md) and
|
those responsibilities belong to [report registry](report-registry.md) and
|
||||||
[briefing](briefing.md).
|
[briefing](briefing.md).
|
||||||
@@ -11,10 +11,10 @@ those responsibilities belong to [report registry](report-registry.md) and
|
|||||||
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
||||||
`PromptValue`. `DataPackageValue` returns the prompt value when present and
|
`PromptValue`. `DataPackageValue` returns the prompt value when present and
|
||||||
otherwise the rich value. This permits custom prompt exports without shrinking
|
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||||
the template and inspection value.
|
the template value.
|
||||||
|
|
||||||
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
|
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
|
||||||
validates it. Snapshot JSON persists IDs, stanza names, and rich values only;
|
representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
|
||||||
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
||||||
stanza into a caller-supplied type, reporting a missing stanza separately from
|
stanza into a caller-supplied type, reporting a missing stanza separately from
|
||||||
a decoding error.
|
a decoding error.
|
||||||
@@ -43,11 +43,12 @@ The registry declares these ordered default compositions:
|
|||||||
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
|
||||||
definitions may narrow it as shown above. Option shape and report compatibility
|
definitions may narrow it as shown above. Option shape and report compatibility
|
||||||
are validated by the briefing registry.
|
are validated by the briefing registry. Accepted typed option pointers are
|
||||||
|
canonicalized to the declared value type before a module builder receives them.
|
||||||
|
|
||||||
## Rich and prompt-facing values
|
## Rich and prompt-facing values
|
||||||
|
|
||||||
Rich values remain available to snapshots, comparisons, and render contexts.
|
Rich values remain available to module snapshots and render contexts.
|
||||||
Briefing attaches custom prompt exports only for current conditions, hourly
|
Briefing attaches custom prompt exports only for current conditions, hourly
|
||||||
forecast, and derived daypart summaries; all other current builders use
|
forecast, and derived daypart summaries; all other current builders use
|
||||||
pass-through values. The prompt package owns how exported stanzas are grouped
|
pass-through values. The prompt package owns how exported stanzas are grouped
|
||||||
|
|||||||
37
docs/internal/prepared-report.md
Normal file
37
docs/internal/prepared-report.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Prepared Report Internals
|
||||||
|
|
||||||
|
`internal/app` validates the report's generated-text catalog binding during
|
||||||
|
prompt inspection, before collection, and carries the resulting handler into
|
||||||
|
`preparedReport` construction after collection. This is the immutable boundary
|
||||||
|
shared by ordinary report generation and profile comparison; it is not a
|
||||||
|
durable artifact.
|
||||||
|
|
||||||
|
Preparation first establishes one `PreparedIdentity` for the report run, report
|
||||||
|
and prompt IDs, variant, generation time, units, timezone, valid period,
|
||||||
|
location, and source warnings. It passes that identity to the configured module
|
||||||
|
snapshot, curated prompt-input package, serialized YAML, generated-text render
|
||||||
|
context, and generated-text definition. Each boundary projects only the fields
|
||||||
|
it needs from that prepared authority.
|
||||||
|
|
||||||
|
Preparation deep-copies mutable facts, snapshots, identity, and data-package
|
||||||
|
bytes before returning them. Consumers receive independent copies so one
|
||||||
|
execution cannot change another's input or rendering context.
|
||||||
|
|
||||||
|
Before accepting generated JSON, the execution boundary reconciles the prepared
|
||||||
|
report definition, inspected prompt hash and selected profile identity, the one
|
||||||
|
preparation callback, and the completed Promptkit result. The callback and
|
||||||
|
completion must agree on prompt, profile, backend, model, and rendered/input
|
||||||
|
hashes; the callback output and completed validation must name the prepared
|
||||||
|
report's JSON Schema. A mismatch produces no rendered Markdown and leaves
|
||||||
|
results with only the inspected safe identity.
|
||||||
|
|
||||||
|
Single-report generation executes one prepared profile and publishes its
|
||||||
|
Markdown. Comparison prepares once, gives every selected profile the same YAML
|
||||||
|
bytes, and only then assembles the resulting logical bundle. The prompt-input
|
||||||
|
shape is owned by [prompt-input internals](prompt-input.md); profile execution
|
||||||
|
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
|
||||||
|
|
||||||
|
Catalog incompatibility stops prompt inspection before weather collection or
|
||||||
|
model work. Preparation failure has no publication side effects. Tests for this
|
||||||
|
boundary cover catalog-preflight ordering, mutation isolation, byte equality,
|
||||||
|
and reuse by both execution paths.
|
||||||
@@ -1,61 +1,36 @@
|
|||||||
# Prompt Input Internals
|
# Prompt Input Internals
|
||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` turns prepared report metadata and an ordered module
|
||||||
Recent Changes, and source warnings into the YAML `data_package` consumed by
|
snapshot into the YAML data package passed to Promptkit. The externally visible
|
||||||
Promptkit. It owns this package's schema, grouping, serialization, loading,
|
prompt and inline-input contract is owned by the [Promptkit integration
|
||||||
and validation—not weather collection, module construction, path choice, or
|
guide](../integrations/promptkit.md); preparation of the inputs is owned by
|
||||||
provider execution.
|
[prepared report internals](prepared-report.md).
|
||||||
|
|
||||||
## Package construction
|
## Package Construction
|
||||||
|
|
||||||
`Build` produces `weatherreporter.data_package.v3`. It copies the run ID;
|
`Build` projects report identity, the report-local current date, source-warning
|
||||||
report ID, variant, prompt ID, generation time, timezone, local current date,
|
summaries, and each snapshot output's prompt-facing value into a package. It
|
||||||
and valid period; ordered briefing stanzas; Recent Changes; and source
|
does not expose source transport or provenance details. The module snapshot
|
||||||
warnings. A nil Recent Changes slice becomes an empty `items` list.
|
defines stanza order and selects curated prompt values; the corresponding
|
||||||
|
module contracts are documented in [module internals](module.md) and [briefing
|
||||||
|
internals](briefing.md).
|
||||||
|
|
||||||
Briefing starts as a flat snapshot order and stanza-value map. `Build` uses
|
`MarshalYAML` validates the package before serializing it. Serialization emits
|
||||||
each output's `DataPackageValue`, so runtime prompt exports take precedence and
|
the metadata stanza first, then groups the remaining recognized stanzas in the
|
||||||
rich values are used only as a fallback. Prompt exports are selected by the
|
package's fixed category order while preserving snapshot order within a
|
||||||
[briefing registry](briefing.md), while the rich-versus-prompt contract is in
|
category. `Validate` enforces the supported schema version, required report
|
||||||
[module internals](module.md).
|
identity and period values, and a nonempty, complete ordered briefing.
|
||||||
|
|
||||||
## YAML ordering and grouping
|
This package does not collect weather, choose an output destination, execute a
|
||||||
|
provider, or persist data packages. The application passes its in-memory YAML
|
||||||
|
to the Promptkit adapter as part of prepared report execution.
|
||||||
|
|
||||||
Serialization keeps `metadata` directly under `briefing`. Every other known
|
## Verification
|
||||||
stanza is placed in exactly one category, emitted in category order and in its
|
|
||||||
original snapshot order within that category:
|
|
||||||
|
|
||||||
| Category | Current stanzas |
|
Focused tests cover package construction, report-local dates, validation,
|
||||||
| --- | --- |
|
curated snapshot exports, deterministic YAML grouping, and safe source-warning
|
||||||
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
projection:
|
||||||
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
|
|
||||||
| `narrative_products` | narrative forecast, discussions, and weather story |
|
|
||||||
| `raw_data` | current conditions and hourly forecast |
|
|
||||||
|
|
||||||
This YAML presentation does not alter the flat snapshot model. `LoadYAML`
|
|
||||||
accepts the same category layout and reconstructs flat `Order` and `Values`,
|
|
||||||
rejecting misplaced, duplicate, unknown, or uncategorized stanzas.
|
|
||||||
|
|
||||||
## Validation and persistence
|
|
||||||
|
|
||||||
`Validate` requires the current schema version, run and report identifiers,
|
|
||||||
prompt ID, generation timestamp, timezone, current local date, valid period,
|
|
||||||
and at least one ordered briefing stanza. It rejects duplicate stanza names,
|
|
||||||
missing values, and a missing category for every non-metadata stanza.
|
|
||||||
|
|
||||||
`MarshalYAML` and `LoadYAML` validate their result. `Save` writes the serialized
|
|
||||||
YAML atomically; managed workspace paths are owned by [state internals](state.md).
|
|
||||||
Generated-text artifacts and template render contexts are later workflow
|
|
||||||
artifacts, not members of this package.
|
|
||||||
|
|
||||||
## Verification and invariants
|
|
||||||
|
|
||||||
Focused tests cover construction, curated exports, category ordering, YAML
|
|
||||||
round trips, invalid layout, validation, and atomic saves:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/promptinput
|
go test ./internal/promptinput
|
||||||
```
|
```
|
||||||
|
|
||||||
The package is narrower than a template render context and never infers changes
|
|
||||||
from report prose.
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
# Promptkit Adapter Internals
|
# Promptkit Adapter Internals
|
||||||
|
|
||||||
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit.
|
`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 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
|
The adapter supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, the embedded fallback catalog, and its built-in catalog; the adapter does not parse profile YAML, merge sources, or probe endpoints.
|
||||||
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
|
The adapter exposes exact prompt and profile validation plus prepared execution. It maps safe prompt identity, logical profile, effective backend/model, preparation, execution, validation, and optional debug values into `promptexec`. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
|
||||||
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,
|
The application uses the preparation callback to record active safe provenance in memory and optionally writes content-rich diagnostics only through an explicit debug writer. The adapter returns raw output for application validation and rendering. It does not retain application state, render Markdown, choose report definitions, or send Distributor notifications.
|
||||||
choose report definitions, or send Distributor notifications.
|
|
||||||
|
|
||||||
Focused tests:
|
Focused tests:
|
||||||
|
|
||||||
@@ -20,5 +14,4 @@ Focused tests:
|
|||||||
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
|
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
|
||||||
```
|
```
|
||||||
|
|
||||||
The public logical prompt/profile/schema contract is owned by the
|
The public logical prompt/profile/schema contract is owned by the [Promptkit integration guide](../integrations/promptkit.md).
|
||||||
[Promptkit integration guide](../integrations/promptkit.md).
|
|
||||||
|
|||||||
@@ -1,69 +1,38 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
`internal/report` owns the registry of report identities and the data declared
|
`internal/report` owns the in-process registry of report identities and the
|
||||||
for each one: resolution, prompt identity and version, comparison policy,
|
resolution of a report's valid period. Command names and configuration aliases
|
||||||
artifact group, output-copy name, default module composition, and Distributor
|
belong to the [CLI reference](../cli.md) and [configuration
|
||||||
path declarations. The public command syntax is owned by the
|
reference](../config.md), respectively.
|
||||||
[CLI reference](../cli.md); configuration aliases and overrides are owned by
|
|
||||||
the [configuration reference](../config.md).
|
|
||||||
|
|
||||||
## Definitions and resolution
|
## Registry And Resolution
|
||||||
|
|
||||||
Each `Definition` declares a stable ID and display name, prompt ID, generation
|
`DefaultRegistry` supplies the maintained definitions. `Lookup` returns a
|
||||||
version, template and generated-text schema IDs, valid-period resolver,
|
definition by its internal ID, while `Resolve` combines it with a request time,
|
||||||
comparison strategy, artifact group, batch-copy filename, Distributor path
|
location, and optional date to produce `Resolved`. The result carries the
|
||||||
templates, generation eligibility, compatible prior IDs, default modules, and
|
definition, generation time, timezone, and resolved valid period; its metadata
|
||||||
batch eligibility flags. `Resolved` combines that definition with the valid
|
and output-name helpers keep derived identity values consistent for callers.
|
||||||
period and run metadata for one invocation.
|
|
||||||
|
|
||||||
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
|
Definitions carry the internal collaborators needed downstream: prompt and
|
||||||
| --- | --- | --- | --- | --- | --- |
|
template identity, module configuration, output naming, Distributor path
|
||||||
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
|
templates, and fixed batch eligibility. The external prompt contract is owned
|
||||||
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` |
|
by the [Promptkit integration guide](../integrations/promptkit.md), template
|
||||||
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
|
surface by the [report template guide](../templates.md), and published
|
||||||
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `hourly.md` |
|
Distributor paths by the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||||
|
|
||||||
Each report pairs its ID and prompt version with matching template and schema
|
`WithModuleOverrides` returns an independently cloned registry with replacement
|
||||||
IDs. Exact template fields and schema assets belong to [report templates](../templates.md)
|
module configuration for recognized report IDs. The application owns batch
|
||||||
and [generated-text internals](generatedtext.md).
|
planning and data-dependent inclusion; see [app orchestration
|
||||||
|
internals](app-orchestration.md).
|
||||||
|
|
||||||
All valid periods are half-open.
|
The registry never collects weather data, parses CLI flags, writes output,
|
||||||
|
executes Promptkit, or delivers a report.
|
||||||
|
|
||||||
## Registry collaborators
|
## Verification
|
||||||
|
|
||||||
`DefaultRegistry` is the only source of the four report definitions.
|
Focused tests protect retained report definitions, period resolution, Daily
|
||||||
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
|
run-ID disambiguation, and rejection of retired command or configuration names:
|
||||||
report identity rules. Registry overrides clone a definition and replace its
|
|
||||||
module list only after the report ID is recognized.
|
|
||||||
|
|
||||||
The definition's `DistributorPathTemplates` are internal declarations consumed
|
|
||||||
by app orchestration. Their rendered external bundle paths and compatibility
|
|
||||||
contract are documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md), not repeated here.
|
|
||||||
|
|
||||||
`morning` and `evening` are registry-owned batch names. Registry flags declare
|
|
||||||
fixed report eligibility; app orchestration determines data-dependent Daily
|
|
||||||
membership and produces the actual batch plan.
|
|
||||||
|
|
||||||
## Module composition and failures
|
|
||||||
|
|
||||||
Each definition supplies an ordered `[]module.ConfigItem`; the complete
|
|
||||||
report-to-module mapping is maintained in [module internals](module.md).
|
|
||||||
`ArtifactGroup`, `BatchOutputName`, and comparison compatibility
|
|
||||||
are likewise consumed by state and orchestration rather than recomputed there.
|
|
||||||
|
|
||||||
Unknown report IDs or batch names return errors. The registry never collects
|
|
||||||
weather data, builds modules, parses CLI flags, writes state, executes
|
|
||||||
Promptkit, or delivers a report.
|
|
||||||
|
|
||||||
## Verification and invariants
|
|
||||||
|
|
||||||
Focused tests cover definition completeness, command and alias lookup, period
|
|
||||||
resolution, run IDs, path declarations, composition defaults, and override
|
|
||||||
validation:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/report
|
go test ./internal/report
|
||||||
```
|
```
|
||||||
|
|
||||||
All report selection goes through the registry, and the registry is the source
|
|
||||||
of truth for report identity—not rendered report text or app-local constants.
|
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ failures actionable with template or partial context.
|
|||||||
Top-level templates decide which shared partials they invoke. The current
|
Top-level templates decide which shared partials they invoke. The current
|
||||||
partials cover daypart forecast variants, alert digest, and precipitation
|
partials cover daypart forecast variants, alert digest, and precipitation
|
||||||
timing. Template code receives curated typed contexts rather than raw data
|
timing. Template code receives curated typed contexts rather than raw data
|
||||||
packages, and it must not reimplement weather selection or generated-text
|
packages or complete fact bundles, and it must not reimplement weather
|
||||||
validation.
|
selection or generated-text validation. Context construction rejects
|
||||||
|
report-identity disagreements before template execution. Every generated-prose
|
||||||
|
insertion uses the `plainText` helper. It retains ordinary prose and paragraph
|
||||||
|
breaks but renders Markdown/HTML syntax, code indentation, and control
|
||||||
|
characters as safe text, so the repository templates remain the sole owners of
|
||||||
|
report structure.
|
||||||
|
|
||||||
## Boundaries and verification
|
## Boundaries and verification
|
||||||
|
|
||||||
@@ -38,8 +43,8 @@ text, construct contexts, resolve report definitions, write state, execute
|
|||||||
Promptkit, 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 template lookup, rendering, partial
|
Focused tests cover template lookup, rendering, partial behavior, daypart
|
||||||
behavior, missing keys, and malformed context:
|
fallbacks, missing keys, and malformed context:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/reporttemplate
|
go test ./internal/reporttemplate
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# State Internals
|
|
||||||
|
|
||||||
`internal/state` owns safe workspace paths, atomic artifact writes, metadata,
|
|
||||||
prior-snapshot lookup, and read-only inspection. Operators should use the
|
|
||||||
[operations guide](../operations.md) for lifecycle and retention.
|
|
||||||
|
|
||||||
## Artifact Paths
|
|
||||||
|
|
||||||
For each run, paths are grouped by artifact group and valid start date:
|
|
||||||
|
|
||||||
| Artifact | Location |
|
|
||||||
| --- | --- |
|
|
||||||
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
|
|
||||||
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
|
|
||||||
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
|
|
||||||
| Prompt preparation | `preflight/<group>/<date>/prompt_preparation.<run-id>.json` |
|
|
||||||
| Prompt execution | `snapshots/<group>/<date>/prompt_execution.<run-id>.json` |
|
|
||||||
| Raw generated text | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` |
|
|
||||||
| Validated generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
|
|
||||||
| Render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
|
|
||||||
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
|
|
||||||
| Notification | `notifications/<group>/<date>/distributor.<run-id>.json` |
|
|
||||||
|
|
||||||
Batch notification records are `notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
|
|
||||||
|
|
||||||
## Metadata And Debug Storage
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Prompt preparation and execution records are validated on both save and load.
|
|
||||||
They require exact report/prompt identity, complete timing, internally
|
|
||||||
consistent provenance, and status-appropriate validation or bounded classified
|
|
||||||
errors. Completed execution provenance keeps Promptkit's run identity distinct
|
|
||||||
from the Weatherreporter run identity.
|
|
||||||
|
|
||||||
For a completed prompt run, the execution record is atomically replaced after
|
|
||||||
each downstream artifact is saved. Its path set therefore records the raw and
|
|
||||||
normalized generated text, render context, managed report, requested output
|
|
||||||
copy, and notification artifact actually reached without changing the original
|
|
||||||
Promptkit outcome.
|
|
||||||
|
|
||||||
`PromptDebugWriter` is separate from workspace state. An empty root disables
|
|
||||||
it. An enabled absolute root is checked for safe directories and symlinks, then
|
|
||||||
stores `preparation.json` and `execution.json` beneath
|
|
||||||
`<root>/<report-id>/<valid-date>/<run-id>/`. Directories are `0700`; files are
|
|
||||||
atomic `0600`. Normal state discovery does not read this root.
|
|
||||||
|
|
||||||
Focused checks:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/state
|
|
||||||
```
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Weather Data Internals
|
# Weather Data Internals
|
||||||
|
|
||||||
`internal/weatherdata` owns the normalized, wire-independent weather bundle
|
`internal/weatherdata` owns the normalized, wire-independent weather bundle
|
||||||
that passes from collection through rendering and persistence. The Weather API
|
that passes from collection through rendering. The Weather API
|
||||||
adapter translates provider responses into these types; its request, response,
|
adapter translates provider responses into these types; its request, response,
|
||||||
and availability contract is documented in the
|
and availability contract is documented in the
|
||||||
[Weather API integration guide](../integrations/weatherapi.md).
|
[Weather API integration guide](../integrations/weatherapi.md).
|
||||||
@@ -31,6 +31,10 @@ provider endpoint or retry policy from the normalized types. See
|
|||||||
[collection](collect.md) for assembly and
|
[collection](collect.md) for assembly and
|
||||||
[report templates](../templates.md) for the values exposed to authors.
|
[report templates](../templates.md) for the values exposed to authors.
|
||||||
|
|
||||||
|
An alert run retains its check time and individual alert payloads for overlap
|
||||||
|
selection. Its source entry retains provider provenance; the full provider
|
||||||
|
envelope is not carried into the normalized bundle.
|
||||||
|
|
||||||
## Source provenance
|
## Source provenance
|
||||||
|
|
||||||
Every checked source is represented by a `Source` entry. The record identifies
|
Every checked source is represented by a `Source` entry. The record identifies
|
||||||
@@ -45,6 +49,10 @@ marked missing only when the adapter's missing-source policy treats the
|
|||||||
response or parsing failure as unavailable. The policy itself belongs to the
|
response or parsing failure as unavailable. The policy itself belongs to the
|
||||||
[configuration reference](../config.md).
|
[configuration reference](../config.md).
|
||||||
|
|
||||||
|
Accepted hourly forecast periods always have nonzero start and end times, with
|
||||||
|
the end after the start. Collection rejects a required hourly product that does
|
||||||
|
not meet those bounds before it enters downstream derivation.
|
||||||
|
|
||||||
## Warning semantics
|
## Warning semantics
|
||||||
|
|
||||||
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
||||||
@@ -54,8 +62,7 @@ local provenance and whole-run consumers see it. A policy that treats a missing
|
|||||||
source as an error returns no partial bundle.
|
source as an error returns no partial bundle.
|
||||||
|
|
||||||
Warnings describe data completeness, not rendering or delivery failures.
|
Warnings describe data completeness, not rendering or delivery failures.
|
||||||
Those failures are recorded by the application and state layers; see
|
Those failures are reported by [application orchestration](app-orchestration.md).
|
||||||
[application orchestration](app-orchestration.md) and [state internals](state.md).
|
|
||||||
|
|
||||||
## Boundaries and verification
|
## Boundaries and verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,153 @@
|
|||||||
# Weatherreporter Operations
|
# Weatherreporter Operations
|
||||||
|
|
||||||
This guide covers normal operation, managed workspace state, inspection,
|
This guide covers normal output handling, Distributor notification, secure
|
||||||
recovery, and operational caveats. See the [CLI reference](cli.md) for complete
|
prompt diagnostics, and cleanup of legacy application state. See the [CLI
|
||||||
command syntax and the [configuration reference](config.md) for fields,
|
reference](cli.md) for command syntax and the [configuration reference](config.md)
|
||||||
defaults, and notification templates. For symptom-based diagnosis, see
|
for fields, defaults, and notification templates.
|
||||||
[Troubleshooting](troubleshooting.md).
|
|
||||||
|
|
||||||
## Normal Operation
|
## Normal Operation
|
||||||
|
|
||||||
After configuring a Weather API endpoint, generate one report:
|
After configuring a Weather API endpoint, generate one report:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate today --out ./today.md
|
weatherreporter generate today
|
||||||
```
|
```
|
||||||
|
|
||||||
A generation collects weather data, resolves the report period, builds and
|
With no configured output directory, the command writes `today.md` in the
|
||||||
persists the module snapshot and prompt data package, records Promptkit
|
current directory. Set `output.directory` to use one ordinary publication
|
||||||
preparation provenance before provider execution, then persists raw output and
|
directory for reports, or choose a one-command operator-owned file with
|
||||||
execution provenance, validates the structured generated text, and renders the
|
`--out`; a relative path is resolved from the current directory and an absolute
|
||||||
managed Markdown report from the validated text and deterministic values.
|
path is used directly. The explicit flag takes precedence over the configured
|
||||||
|
directory. Weatherreporter renders in memory and atomically replaces the
|
||||||
|
selected destination only after generation and rendering succeed. It does not
|
||||||
|
create a default workspace, metadata, receipts, or intermediate output files.
|
||||||
|
|
||||||
The managed report and its final metadata are saved before single-report
|
A missing configured directory is created only as part of successful report
|
||||||
Distributor notification is attempted. `--out` writes an extra operator copy;
|
publication. If its existing path is not a directory or cannot be inspected,
|
||||||
it never changes the managed report or upload source. A successful generate
|
the command stops before prompt inspection or weather collection, leaving any
|
||||||
command prints its summary to stdout unless `--quiet` is used.
|
existing report unchanged. See the [configuration reference](config.md) for the
|
||||||
|
field definition and validation rules.
|
||||||
|
|
||||||
|
Before a destination is published, provider, validation, rendering, write, and
|
||||||
|
cancellation failures leave an existing report unchanged. A notification
|
||||||
|
failure happens after publication, so retain and use the completed Markdown
|
||||||
|
file while resolving the delivery error. The JSON result identifies the
|
||||||
|
absolute output path and active profile, backend, model, warnings, validation,
|
||||||
|
debug, and notification information; see the [CLI reference](cli.md) for its
|
||||||
|
exact fields.
|
||||||
|
|
||||||
|
Weatherreporter validates the final output filename before prompt inspection or
|
||||||
|
weather collection. A valid long filename is published through a short,
|
||||||
|
same-directory temporary sibling, so temporary naming does not shorten the
|
||||||
|
operator-selected destination. A rejected filename does not create a missing
|
||||||
|
parent directory. The final destination itself must be absent or a regular
|
||||||
|
file: symlinks, directories, named pipes, sockets, and other special objects
|
||||||
|
are rejected before prompt inspection or weather collection. The destination is
|
||||||
|
checked again immediately before the atomic replacement; cancellation or a
|
||||||
|
deadline at that point leaves the prior report unchanged and skips notification.
|
||||||
|
|
||||||
|
`SIGINT` and `SIGTERM` request orderly cancellation of an active action. The
|
||||||
|
command lets cancellation and related cleanup finish before it exits; use the
|
||||||
|
usual failed result or error to determine whether an output was published.
|
||||||
|
|
||||||
|
## Batch Outputs And Distributor Notification
|
||||||
|
|
||||||
|
Run a scheduled batch with an explicit output directory when appropriate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter run morning --out-dir ./reports
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `--out-dir`, batch reports are written beneath `output.directory` when
|
||||||
|
configured, otherwise the current directory. The explicit directory applies
|
||||||
|
only to that command and takes precedence over the configured fallback.
|
||||||
|
Morning runs Today, Tomorrow, and every eligible dated Daily Report; evening
|
||||||
|
runs Tomorrow and the same eligible Daily Reports. Eligible Daily dates begin
|
||||||
|
after tomorrow and require complete hourly coverage for their local civil day.
|
||||||
|
A batch collects once, determines the complete report set, and validates every
|
||||||
|
final output destination before executing its first report prompt. A destination
|
||||||
|
collision, such as a directory named `tomorrow.md`, stops the batch before any
|
||||||
|
report output is created or replaced. After successful validation, each selected
|
||||||
|
report processes independently and successful outputs remain available if
|
||||||
|
another report fails. If cancellation or a deadline is observed during the
|
||||||
|
sequence, Weatherreporter stops before starting another report. It retains
|
||||||
|
already published files, marks interrupted and unstarted reports as canceled in
|
||||||
|
the result, and skips batch notification.
|
||||||
|
|
||||||
|
When `notify.distributor.enabled` and batch notification are enabled,
|
||||||
|
Weatherreporter sends one Distributor upload only after every selected output
|
||||||
|
exists. If an item fails, the batch notification is skipped and successful
|
||||||
|
files remain at their selected destinations. A batch notification failure also
|
||||||
|
leaves all successfully published report files in place. Distributor source
|
||||||
|
files are those operator-owned Markdown outputs; rendered bundle paths and
|
||||||
|
delivery status appear in the result, not in a local notification receipt.
|
||||||
|
Remote Distributor response text is not included in command output. Instead,
|
||||||
|
notification failures use stable local diagnostics while retaining the upload
|
||||||
|
and status identities needed to investigate delivery with Distributor.
|
||||||
|
Report counters count report items only. A batch notification failure therefore
|
||||||
|
returns a failed batch status even when all report counters show success; the
|
||||||
|
top-level notification result contains the delivery diagnostic.
|
||||||
|
|
||||||
|
For a single report, Distributor notification follows the atomic output write.
|
||||||
|
Enabled notification configuration, including the HTTP(S) endpoint and
|
||||||
|
templates, is validated before report processing. A malformed endpoint does not
|
||||||
|
collect weather data, generate a report, publish output, or invoke Distributor.
|
||||||
|
See the [configuration reference](config.md) for endpoint, pipeline, bundle,
|
||||||
|
idempotency-key, and per-report path templates.
|
||||||
|
|
||||||
|
## Comparison Bundles
|
||||||
|
|
||||||
|
Use `compare` when an operator needs to evaluate explicit Promptkit profiles
|
||||||
|
against the same report input. The command writes one flat, operator-owned
|
||||||
|
bundle directory and never sends a Distributor notification. Command syntax,
|
||||||
|
profile validation, JSON output, and exit behavior belong to the
|
||||||
|
[CLI reference](cli.md); the durable file contract belongs to the
|
||||||
|
[comparison bundle contract](integrations/comparison-bundle.md).
|
||||||
|
|
||||||
|
The output destination follows the normal `output.directory` fallback. An
|
||||||
|
explicit `--out-dir` takes precedence and names the exact bundle directory,
|
||||||
|
not a parent to be combined with another name. The standard names are derived
|
||||||
|
from the report output name, such as `comparison-today` and
|
||||||
|
`comparison-daily-2026-05-29`; see the [configuration reference](config.md)
|
||||||
|
for output-directory resolution.
|
||||||
|
|
||||||
|
A comparison bundle contains the shared data package, a manifest, and one
|
||||||
|
Markdown file for every successful profile. Treat all of these files as
|
||||||
|
potentially sensitive: the data package and generated reports can contain
|
||||||
|
location or forecast context. Weatherreporter creates no application-owned
|
||||||
|
history, retention store, or cleanup job. Retain, archive, or remove only the
|
||||||
|
specific bundle directories your operating policy permits.
|
||||||
|
|
||||||
|
The destination is preflighted before prompt inspection and collection, then
|
||||||
|
rechecked immediately before an atomic publish. A missing or empty directory
|
||||||
|
is usable. A nonempty directory can be replaced only when `--replace` is given
|
||||||
|
and it is recognized as a current Weatherreporter comparison bundle; ordinary
|
||||||
|
directories, symlinks, and unsafe destinations are rejected. Cancellation and
|
||||||
|
all failures before publication preserve an existing bundle, including a
|
||||||
|
cancellation observed while a replacement is being prepared. If guarded
|
||||||
|
restoration cannot complete, the error names the retained sibling bundle for
|
||||||
|
manual recovery. Profile failures are different: the command publishes a
|
||||||
|
complete partial bundle, with failed profiles represented in the manifest and
|
||||||
|
no Markdown file for those profiles.
|
||||||
|
Comparison preflight also checks that private publication siblings can be
|
||||||
|
formed. An infeasible destination name is rejected before a missing parent
|
||||||
|
directory is created.
|
||||||
|
|
||||||
|
## Local Prompt Profile Override
|
||||||
|
|
||||||
|
Hourly normally selects the embedded `weather-light` profile. To use a local
|
||||||
|
OpenAI-compatible model without changing prompts or application code, copy
|
||||||
|
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml),
|
||||||
|
set its `endpoint` and `model` for the local server, and configure the copy as
|
||||||
|
`promptkit.profile_file`. The profile file's `weather-light` definition
|
||||||
|
completely replaces the embedded definition; it does not affect a report that
|
||||||
|
selects another profile ID.
|
||||||
|
|
||||||
|
Prompt and profile validation occurs before weather collection. A malformed
|
||||||
|
profile file, missing required credential, or unsupported selected backend
|
||||||
|
stops the command before collection. A reachable profile can still fail later
|
||||||
|
if its local model endpoint is unavailable; Weatherreporter does not switch to
|
||||||
|
a remote profile.
|
||||||
|
|
||||||
## Optional Prompt Debug Capture
|
## Optional Prompt Debug Capture
|
||||||
|
|
||||||
@@ -33,146 +157,71 @@ Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
|
|||||||
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
```
|
```
|
||||||
|
|
||||||
The directory must be absolute and is initialized before prompt inspection or
|
The directory must be absolute. Requested captures are written with restrictive
|
||||||
weather collection. Capture files are stored outside the managed workspace,
|
permissions beneath the supplied directory, organized by report and run. They
|
||||||
with restrictive permissions, under the report ID, valid date, and RunID.
|
can contain rendered prompts and generated output, so limit access to trusted
|
||||||
They can contain rendered prompts and generated output, so the normal metadata,
|
operators and remove the captures when they are no longer needed. Comparison
|
||||||
CLI summary, and routine logs contain only the optional directory path—not
|
captures additionally identify each selected profile so concurrent executions
|
||||||
their content. A capture-write failure stops that run before later work can
|
remain distinct. Normal output, summaries, and routine logs omit that sensitive
|
||||||
continue.
|
content. Debug capture is never created for an ordinary command without
|
||||||
|
`--llm-debug-dir`.
|
||||||
|
|
||||||
Run a scheduled batch with the same configured collection:
|
Secure prompt debug capture is currently available only on Unix hosts, where
|
||||||
|
Weatherreporter can keep every traversal and write anchored to opened directory
|
||||||
|
descriptors without following symbolic links. On other platforms, requesting
|
||||||
|
`--llm-debug-dir` fails before prompt inspection, weather collection, or
|
||||||
|
provider execution; ordinary commands without the flag remain available.
|
||||||
|
|
||||||
|
Preparation captures retain only the provider endpoint origin and reviewed
|
||||||
|
execution settings. URL user information, paths, queries, fragments, and
|
||||||
|
unrecognized provider parameters are omitted.
|
||||||
|
|
||||||
|
Capture writes are confined to the requested root and fail if an unsafe
|
||||||
|
filesystem component prevents secure artifact creation.
|
||||||
|
|
||||||
|
If capture creation or writing fails, the affected run fails rather than
|
||||||
|
silently continuing without the requested diagnostics.
|
||||||
|
|
||||||
|
## Diagnosing Failures
|
||||||
|
|
||||||
|
Start with the command error and JSON summary. For a report generation failure,
|
||||||
|
the selected destination was not replaced; for a notification failure, inspect
|
||||||
|
the completed destination and the notification result. For a batch failure,
|
||||||
|
use the per-report statuses and retain successful output files. For a comparison
|
||||||
|
failure, inspect the published manifest when its path is present: individual
|
||||||
|
profile failures retain their safe result and successful Markdown files, while
|
||||||
|
cancellation and pre-publication errors leave the prior destination unchanged.
|
||||||
|
|
||||||
|
If a replacement commits but cleanup of its prior sibling backup fails, the new
|
||||||
|
bundle remains valid and its artifact paths appear in the failed command
|
||||||
|
summary. The summary records a safe `publication_cleanup` error that indicates
|
||||||
|
whether a complete prior bundle remains, only partial remnants remain, or no
|
||||||
|
prior bundle remains; it also identifies when the sibling cannot be inspected.
|
||||||
|
The returned command error includes a recovery path only when a sibling remains.
|
||||||
|
Preserve a complete recognized recovery bundle until it has been inspected and
|
||||||
|
cleaned up manually; partial remnants are not a rollback artifact. Do not
|
||||||
|
remove the new bundle to retry cleanup.
|
||||||
|
|
||||||
|
Enable explicit debug capture only when content-rich Promptkit diagnostics are
|
||||||
|
necessary.
|
||||||
|
|
||||||
|
Weatherreporter does not retain runs for later inspection, resume failed work,
|
||||||
|
or provide automatic cleanup, archival, remote state, daemon operation, or
|
||||||
|
automatic storm monitoring.
|
||||||
|
|
||||||
|
## Manual Cleanup Of Legacy Workspaces
|
||||||
|
|
||||||
|
Older installations may have a directory named `workspace` containing reports,
|
||||||
|
snapshots, prompt inputs, or notification records from previous versions.
|
||||||
|
Current commands neither read nor update it. After confirming that no separate
|
||||||
|
retention requirement applies, remove that specific legacy directory manually;
|
||||||
|
do not use a broad cleanup command that could remove current operator outputs.
|
||||||
|
|
||||||
|
For example, from the directory that contains the old directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
rm -rf ./workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Each batch validates its configured prompt/profile candidates, then collects once before it plans reports. Morning runs Today, Tomorrow,
|
This removal cannot be recovered by Weatherreporter. Keep or archive any
|
||||||
and every eligible dated Daily Report; evening runs Tomorrow and the same
|
historical files that are still needed before deleting them.
|
||||||
eligible Daily Reports. Eligible Daily dates begin after tomorrow and require
|
|
||||||
complete hourly coverage for their entire local civil day. A batch continues
|
|
||||||
after an individual report fails and returns an aggregate failure when any
|
|
||||||
report or batch notification fails.
|
|
||||||
|
|
||||||
`--out-dir` writes extra copies such as `today.md`, `tomorrow.md`, and
|
|
||||||
`daily-YYYY-MM-DD.md`. These copies are never upload sources. Batch report
|
|
||||||
copies and notification behavior are summarized in the CLI result; use the
|
|
||||||
[CLI reference](cli.md) for its exact JSON and stderr contract.
|
|
||||||
|
|
||||||
## Managed Workspace
|
|
||||||
|
|
||||||
The default workspace root is `workspace`. Artifact paths use the report
|
|
||||||
definition's artifact group, the valid-period start date in the effective
|
|
||||||
timezone, and the RunID:
|
|
||||||
|
|
||||||
```text
|
|
||||||
workspace/
|
|
||||||
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
|
||||||
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/prompt_execution.<run_id>.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
|
||||||
|
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/prompt_preparation.<run_id>.json
|
|
||||||
|
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
|
||||||
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated-text and render-context artifacts are written for every completed
|
|
||||||
single-report generation.
|
|
||||||
A report's metadata links the module snapshot, data package, preparation and
|
|
||||||
execution receipts, managed report, generated-text artifacts, and any available single-report
|
|
||||||
notification artifact. Batch notification artifacts are separate batch-level
|
|
||||||
records under `notifications/batches`.
|
|
||||||
|
|
||||||
RunIDs begin with the UTC generation timestamp and report ID. A Daily RunID
|
|
||||||
also contains its local valid date so multiple Daily reports in one batch have
|
|
||||||
different managed paths. Batch notification RunIDs contain the UTC batch start
|
|
||||||
timestamp and batch name.
|
|
||||||
|
|
||||||
## Distributor Notification
|
|
||||||
|
|
||||||
When `notify.distributor.enabled` is enabled, a successful `generate`
|
|
||||||
uploads only the managed Markdown report after final metadata has been saved.
|
|
||||||
The extra copy from `--out` is never uploaded. A notification attempt writes
|
|
||||||
a redacted debug artifact at
|
|
||||||
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`; its
|
|
||||||
path is then recorded in report metadata.
|
|
||||||
|
|
||||||
Batches suppress per-report notification. When both Distributor and its batch
|
|
||||||
notification are enabled, Weatherreporter submits one multi-report upload after
|
|
||||||
every planned report succeeds. If any report fails, it records a top-level
|
|
||||||
`skipped` notification with reason `one or more reports failed` and does not
|
|
||||||
call Distributor. If batch notification is disabled, a batch does not fall back
|
|
||||||
to individual uploads.
|
|
||||||
|
|
||||||
A batch notification attempt writes
|
|
||||||
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`.
|
|
||||||
A notification failure makes the batch fail but does not change successful
|
|
||||||
individual report items into failed items. The debug artifacts contain rendered
|
|
||||||
identifiers, managed source and bundle paths, upload and status results, and
|
|
||||||
redacted errors; they do not contain tokens.
|
|
||||||
|
|
||||||
## Inspecting Stored Runs
|
|
||||||
|
|
||||||
Inspection is read-only: it neither collects weather data nor invokes
|
|
||||||
Promptkit or Distributor. Start by finding a RunID:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter inspect reports --limit 10
|
|
||||||
weatherreporter inspect metadata RUN_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
| Command | Reads |
|
|
||||||
| --- | --- |
|
|
||||||
| `inspect reports` | Metadata files under the workspace snapshots tree. |
|
|
||||||
| `inspect metadata RUN_ID` | Metadata located by RunID. |
|
|
||||||
| `inspect modules RUN_ID` | The module snapshot path recorded in metadata. |
|
|
||||||
| `inspect data-package RUN_ID` | The data-package path recorded in metadata. |
|
|
||||||
| `inspect prior RUN_ID` | The run metadata, then compatible earlier metadata for its comparison policy. |
|
|
||||||
| `inspect sources RUN_ID` | Source provenance and warnings in the run metadata. |
|
|
||||||
|
|
||||||
A missing snapshots directory produces no listed reports. An unknown or empty
|
|
||||||
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
|
|
||||||
|
|
||||||
Keep the workspace when a run fails: artifacts reached before the failure
|
|
||||||
remain available where they can be safely persisted.
|
|
||||||
|
|
||||||
- A preparation failure can leave its classified receipt and metadata.
|
|
||||||
- A report-generation failure can leave the managed report, module snapshot,
|
|
||||||
data package, and metadata.
|
|
||||||
- A completed prompt validation rejection leaves raw text, an execution receipt,
|
|
||||||
and metadata. Later generated-text failures can also leave validated text and
|
|
||||||
a render-context artifact, depending on where they stopped.
|
|
||||||
- A single-report notification failure preserves the report and final metadata,
|
|
||||||
including its notification artifact when it was written.
|
|
||||||
- A batch notification failure preserves each report's artifacts and adds the
|
|
||||||
top-level batch notification artifact.
|
|
||||||
|
|
||||||
Use the RunID from the action summary with the inspection commands above. For
|
|
||||||
a batch failure, inspect the summary first, then inspect the affected report
|
|
||||||
RunIDs or the batch notification path. Do not remove the whole workspace as a
|
|
||||||
first response; retain it until the failure is understood.
|
|
||||||
|
|
||||||
## Operational Caveats
|
|
||||||
|
|
||||||
- Workspace files and generated reports can contain
|
|
||||||
sensitive operational context. Set appropriate filesystem permissions and do
|
|
||||||
not publish them unintentionally.
|
|
||||||
- Weatherreporter uses one configured Weather API endpoint and local workspace
|
|
||||||
state.
|
|
||||||
- It does not provide automatic resume, cleanup, archival, remote state, daemon
|
|
||||||
operation, or automatic storm monitoring.
|
|
||||||
|
|||||||
@@ -10,29 +10,36 @@ package inventory; focused documents in `docs/internal/` own implementation deta
|
|||||||
|
|
||||||
Weatherreporter is a deterministic weather-report CLI. It collects normalized
|
Weatherreporter is a deterministic weather-report CLI. It collects normalized
|
||||||
weather data, derives facts and modules, builds a curated YAML data package,
|
weather data, derives facts and modules, builds a curated YAML data package,
|
||||||
compares prior snapshots, executes exact-version Promptkit prompts, validates
|
executes exact-version Promptkit prompts, validates structured generated prose,
|
||||||
structured generated prose, and renders repository-owned Markdown. Completed
|
and renders repository-owned Markdown in memory. Completed Markdown is
|
||||||
managed Markdown may be uploaded through Distributor.
|
atomically published to an operator-owned output destination and may then be
|
||||||
|
uploaded through Distributor.
|
||||||
|
|
||||||
|
An explicit profile comparison prepares one report input once, executes the
|
||||||
|
same exact prompt and data package across selected profiles concurrently, and
|
||||||
|
atomically publishes one operator-owned comparison bundle. It remains local:
|
||||||
|
it does not create application state or send a Distributor notification.
|
||||||
|
|
||||||
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
|
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
|
||||||
collects once, validates its complete candidate prompt/profile set before
|
collects once, validates its complete candidate prompt/profile set before
|
||||||
collection, then executes planned reports sequentially with one executor. It
|
collection, then determines and validates every planned output destination
|
||||||
continues after independent report failures and sends a batch notification only
|
before executing reports sequentially with one executor. It continues after
|
||||||
after every planned report succeeds.
|
independent report failures and sends a batch notification only after every
|
||||||
|
planned report succeeds.
|
||||||
|
|
||||||
## Ownership And Boundaries
|
## Ownership And Boundaries
|
||||||
|
|
||||||
- `internal/cli` owns command parsing, help, summaries, and one executor
|
- `internal/cli` owns command parsing, help, summaries, and one executor
|
||||||
construction per action.
|
construction per action.
|
||||||
- `internal/config` owns defaults, loading, validation, and secret loading.
|
- `internal/config` owns defaults, loading, validation, and secret loading.
|
||||||
- `internal/app` owns workflow order, partial results, and notification
|
- `internal/app` owns in-memory workflow order, partial results, atomic output
|
||||||
coordination through project-owned contracts.
|
publication, and notification coordination through project-owned contracts.
|
||||||
|
- `internal/comparison` owns comparison identity, durable logical bundle
|
||||||
|
validation, safe destination recognition, and atomic bundle publication.
|
||||||
- Deterministic domain packages own weather derivation, report periods, modules,
|
- Deterministic domain packages own weather derivation, report periods, modules,
|
||||||
generated-text validation, and template contexts.
|
generated-text validation, and template contexts.
|
||||||
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
|
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
|
||||||
`internal/adapters/distributor` own their external dependency mechanics.
|
`internal/adapters/distributor` own their external dependency mechanics.
|
||||||
- `internal/state` owns workspace paths, V2 metadata, atomic persistence, and
|
|
||||||
read-only inspection.
|
|
||||||
|
|
||||||
Dependency-specific Promptkit types remain inside its adapter. The application
|
Dependency-specific Promptkit types remain inside its adapter. The application
|
||||||
does not parse flags, construct provider clients, or render provider output
|
does not parse flags, construct provider clients, or render provider output
|
||||||
@@ -41,28 +48,47 @@ directly.
|
|||||||
## Prompt Execution Invariants
|
## Prompt Execution Invariants
|
||||||
|
|
||||||
- Prompts receive curated module packages, never unbounded raw weather payloads.
|
- Prompts receive curated module packages, never unbounded raw weather payloads.
|
||||||
- Every execution inspects the exact prompt version and output contract before
|
- Every execution validates the exact prompt version and output contract before
|
||||||
collection. The selected profile is configured explicitly or declared by the
|
collection. The selected profile is configured explicitly or declared by the
|
||||||
prompt; unsupported direct-key profiles and missing reported credentials fail
|
prompt; unsupported direct-key profiles and missing reported credentials fail
|
||||||
before collection.
|
before collection.
|
||||||
- Prepared execution persists safe preparation provenance before provider work.
|
- Prompt and profile validation completes before weather collection. Raw output
|
||||||
Completed execution persists safe execution provenance; raw output is
|
is validated before template rendering.
|
||||||
validated before template rendering.
|
- Comparison validates every explicit profile before collection, prepares one
|
||||||
|
immutable report input, and delegates backend capacity to Promptkit rather
|
||||||
|
than adding an application-wide execution limit.
|
||||||
- Generated text fills defined prose slots only. Deterministic facts remain
|
- Generated text fills defined prose slots only. Deterministic facts remain
|
||||||
authoritative and repository-owned templates produce all managed Markdown.
|
authoritative and repository-owned templates produce all Markdown output.
|
||||||
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
||||||
credentials never enter normal metadata, summaries, logs, or workspace
|
credentials never enter normal summaries or logs. They are written only to
|
||||||
artifacts. They are written only to an explicit secure debug root when
|
an explicit secure debug root when requested.
|
||||||
requested.
|
|
||||||
|
|
||||||
## State, Notification, And Testing Invariants
|
## Output, Notification, And Testing Invariants
|
||||||
|
|
||||||
- Managed writes are atomic where practical and stay beneath the configured
|
- Normal execution is stateless: it keeps weather data, prompt input, generated
|
||||||
workspace root. Reached artifacts remain inspectable after later failures.
|
text, and render context in memory and creates no application-owned durable
|
||||||
- New records use `weatherreporter.metadata.v2`; V1 records remain readable for
|
state.
|
||||||
inspection compatibility.
|
- Markdown writes are atomic at an operator-selected destination. A
|
||||||
- Distributor uploads use only the managed Markdown report, never output copies
|
pre-publication failure, including cancellation observed immediately before
|
||||||
or workspace scans. Notification follows report and final metadata success.
|
publication, does not replace an existing destination; a notification failure
|
||||||
|
does not remove a newly published output.
|
||||||
|
- A single-report final destination is either absent or a regular file.
|
||||||
|
Symlinks and special filesystem objects are rejected during preflight and
|
||||||
|
rechecked immediately before the atomic replacement.
|
||||||
|
- Configuration or explicit CLI input selects that operator-owned destination;
|
||||||
|
it does not create an application-owned state boundary.
|
||||||
|
- Comparison bundles are flat, versioned operator outputs. Their guarded
|
||||||
|
replacement accepts only a recognized current bundle; cancellation and every
|
||||||
|
pre-publication failure preserve a prior bundle, while individual profile
|
||||||
|
failures can publish a complete partial bundle.
|
||||||
|
- Distributor uploads use only the published Markdown output, never a scan of
|
||||||
|
local files. Single notification follows publication; batch notification
|
||||||
|
follows publication of every selected report. Batch counters describe report
|
||||||
|
outcomes only; a failed batch notification is represented separately at the
|
||||||
|
batch level.
|
||||||
|
- Comparison never invokes Distributor notification.
|
||||||
|
- Profile comparison supports operator review only: it does not score, rank,
|
||||||
|
select, resample, or replay profile executions.
|
||||||
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
||||||
rather than live provider calls. See the [testing policy](testing.md).
|
rather than live provider calls. See the [testing policy](testing.md).
|
||||||
|
|
||||||
|
|||||||
@@ -85,9 +85,8 @@ mechanisms, not secret values.
|
|||||||
| 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 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. |
|
| Release notes | `docs/releases/` | One versioned, changelog-style summary for each release, including compatibility and operator action. The file at the tagged commit supplies the corresponding Gitea release body. | Current CLI, configuration, operations, integration, architecture, and internal contracts; release procedure; implementation plans. |
|
||||||
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, stdout and stderr behavior, summaries, and exit behavior. | Configuration field definitions, complete operating procedures, runtime filesystem layout, and command implementation. |
|
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, stdout and stderr behavior, summaries, and exit behavior. | Configuration field definitions, complete operating procedures, runtime filesystem layout, and command implementation. |
|
||||||
| Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, runtime state lifecycle, and loading implementation. |
|
| Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, output lifecycle, and loading implementation. |
|
||||||
| Operations | `docs/operations.md` | Normal workflows, physical workspace layout, artifacts and metadata, inspection, notification behavior, recovery, cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
| Operations | `docs/operations.md` | Normal output handling, atomic replacement, notification behavior, diagnosis, explicit debug capture, manual legacy-workspace cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
||||||
| Troubleshooting | `docs/troubleshooting.md` | Recurring symptoms, likely causes, diagnostic steps, safe fixes, and links to normal-operation references. | Complete command and configuration references, routine operating procedures, and implementation detail. |
|
|
||||||
| Report template surface | `docs/templates.md` | Implemented template files and partials, render-context fields, editing rules, and maintainer-facing template examples. | Weather derivation, module implementation, generated-text validation internals, and operator procedures. |
|
| Report template surface | `docs/templates.md` | Implemented template files and partials, render-context fields, editing rules, and maintainer-facing template examples. | Weather derivation, module implementation, generated-text validation internals, and operator procedures. |
|
||||||
| External and durable integration contracts | `docs/integrations/` | Weather API, 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. |
|
| 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. |
|
||||||
@@ -110,13 +109,12 @@ structure and invariants. Focused internal documents own implementation
|
|||||||
behavior. These documents may link to one another but must not maintain
|
behavior. These documents may link to one another but must not maintain
|
||||||
parallel package or behavior references.
|
parallel package or behavior references.
|
||||||
|
|
||||||
### Commands, Configuration, Operations, And Troubleshooting
|
### Commands, Configuration, And Operations
|
||||||
|
|
||||||
CLI documentation answers how to invoke Weatherreporter and what its command
|
CLI documentation answers how to invoke Weatherreporter and what its command
|
||||||
interface does. Configuration documentation answers what settings mean.
|
interface does. Configuration documentation answers what settings mean.
|
||||||
Operations answers what happens to runtime state and how to operate or recover
|
Operations answers how to handle operator-owned outputs and runtime failures,
|
||||||
the application. Troubleshooting starts from a symptom and leads to diagnosis
|
including diagnosis, explicit debug capture, and safe legacy cleanup.
|
||||||
and a safe fix.
|
|
||||||
|
|
||||||
When a workflow crosses these topics, place the complete procedure with the
|
When a workflow crosses these topics, place the complete procedure with the
|
||||||
document that owns the task and link to the other contracts. Do not duplicate
|
document that owns the task and link to the other contracts. Do not duplicate
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ Use each test type where it protects a distinct risk:
|
|||||||
- Integration tests use real deterministic collaborators when correctness
|
- Integration tests use real deterministic collaborators when correctness
|
||||||
depends on their interaction, while replacing live or nondeterministic
|
depends on their interaction, while replacing live or nondeterministic
|
||||||
external boundaries.
|
external boundaries.
|
||||||
- App and CLI tests protect representative assembled generation, batch,
|
- App and CLI tests protect representative assembled generation, batch, atomic
|
||||||
inspection, persistence, and notification workflows.
|
output, and notification workflows.
|
||||||
- Fixtures must be minimal, synthetic, versioned with the behavior they
|
- Fixtures must be minimal, synthetic, versioned with the behavior they
|
||||||
exercise, and free of credentials or private data.
|
exercise, and free of credentials or private data.
|
||||||
- Golden files are appropriate only when the complete output is intentionally
|
- Golden files are appropriate only when the complete output is intentionally
|
||||||
@@ -199,9 +199,9 @@ Each behavior should have a clear test owner:
|
|||||||
- 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, Promptkit/provider, and upload boundaries.
|
- Adapter tests own HTTP, Promptkit/provider, and upload boundaries.
|
||||||
- Orchestrator tests own workflow ordering, persistence, partial success, and
|
- Orchestrator tests own workflow ordering, output publication, partial success,
|
||||||
failure propagation.
|
and failure propagation.
|
||||||
- State tests own path derivation, atomic artifacts, lookup, and round trips.
|
- Filesystem tests own atomic writes and destination-preservation behavior.
|
||||||
- Template and generated-text tests own schemas, render contexts, and rendered
|
- Template and generated-text tests own schemas, render contexts, and rendered
|
||||||
output contracts.
|
output contracts.
|
||||||
|
|
||||||
@@ -219,8 +219,8 @@ observation:
|
|||||||
3. Use stubs when a dependency only needs controlled responses.
|
3. Use stubs when a dependency only needs controlled responses.
|
||||||
4. Use mocks when the interaction itself is contractual.
|
4. Use mocks when the interaction itself is contractual.
|
||||||
|
|
||||||
Mocks are appropriate for requirements such as uploading exactly once, saving
|
Mocks are appropriate for requirements such as uploading exactly once,
|
||||||
metadata before notification, propagating cancellation to Promptkit, or
|
notifying only after output publication, propagating cancellation to Promptkit, or
|
||||||
avoiding an external call after an earlier workflow failure. Do not use mocks
|
avoiding an external call after an earlier workflow failure. Do not use mocks
|
||||||
merely to isolate every object or reproduce the implementation's call graph.
|
merely to isolate every object or reproduce the implementation's call graph.
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ git diff --cached --check
|
|||||||
|
|
||||||
Follow every added or changed Markdown link and confirm that its local target
|
Follow every added or changed Markdown link and confirm that its local target
|
||||||
exists. Review the candidate for generated binaries, test output, credentials,
|
exists. Review the candidate for generated binaries, test output, credentials,
|
||||||
temporary files, workspace files, replacements, vendored dependencies, and
|
temporary files, replacements, vendored dependencies, and other files that do
|
||||||
other files that do not belong in source control.
|
not belong in source control.
|
||||||
|
|
||||||
## Publish The Candidate Commit
|
## Publish The Candidate Commit
|
||||||
|
|
||||||
|
|||||||
140
docs/releases/v0.10.0.md
Normal file
140
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Weatherreporter v0.10.0
|
||||||
|
|
||||||
|
Weatherreporter `v0.10.0` makes report execution stateless, adds stable
|
||||||
|
weather-specific Promptkit profiles, and turns every successful generation
|
||||||
|
into one atomic operator-owned Markdown output.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Ordinary generation no longer creates or depends on a managed workspace,
|
||||||
|
historical run artifacts, metadata, receipts, or prior snapshots.
|
||||||
|
- `generate` and `run` now publish directly to operator-selected paths, with
|
||||||
|
useful current-directory defaults when output flags are omitted.
|
||||||
|
- Local Recent Changes comparison and the historical `inspect` command family
|
||||||
|
have been removed.
|
||||||
|
- Promptkit `v0.5.0` and three embedded logical profiles provide a stable model
|
||||||
|
ladder with complete file- or directory-based overrides.
|
||||||
|
- Prompt input and generated-text contracts have been tightened, and output,
|
||||||
|
cancellation, batch preflight, notification, and partial-failure behavior
|
||||||
|
have focused offline coverage.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This pre-`v1` minor release intentionally breaks CLI, configuration,
|
||||||
|
prompt-input, action-summary, and workspace contracts from `v0.9.0`.
|
||||||
|
|
||||||
|
- The `workspace:` and `recent_change:` configuration sections are no longer
|
||||||
|
supported. Strict configuration loading rejects them.
|
||||||
|
- The `inspect reports`, `inspect metadata`, `inspect modules`,
|
||||||
|
`inspect data-package`, `inspect prior`, and `inspect sources` commands have
|
||||||
|
been removed. Weatherreporter no longer reads V1 or V2 run metadata or other
|
||||||
|
historical workspace artifacts.
|
||||||
|
- Every successful `generate` writes exactly one Markdown file. Without
|
||||||
|
`--out`, Daily writes `daily-YYYY-MM-DD.md` and Today, Tomorrow, and Hourly
|
||||||
|
write `today.md`, `tomorrow.md`, and `hourly.md` in the invocation's current
|
||||||
|
directory. `--out` selects that file rather than creating an extra copy of a
|
||||||
|
separately managed report.
|
||||||
|
- `run` writes selected outputs beneath the current directory unless
|
||||||
|
`--out-dir` selects another directory. Successful items remain available
|
||||||
|
when another batch item fails.
|
||||||
|
- Action summaries no longer expose managed report, metadata, snapshot, data
|
||||||
|
package, prompt preparation, prompt execution, generated-text, render-context,
|
||||||
|
or notification-receipt paths. They retain the final `outputPath`, optional
|
||||||
|
`llmDebugPath`, safe effective profile/backend/model details, validation,
|
||||||
|
warnings, notification status, and safe errors.
|
||||||
|
- Batch report items no longer contain per-report notification fields. Batch
|
||||||
|
notification is represented once at the top level. The `total`, `succeeded`,
|
||||||
|
and `failed` counters describe reports only, so notification failure can
|
||||||
|
produce a failed action while `failed` remains `0`.
|
||||||
|
- The prompt data package advances from `weatherreporter.data_package.v3` to
|
||||||
|
`weatherreporter.data_package.v4` and removes `recent_changes`. All four
|
||||||
|
embedded prompts advance from `1.1.0` to `2.0.0`.
|
||||||
|
- Generated-text schemas now require string-valued `precipitation_timing`; the
|
||||||
|
model returns an empty string when there is no timing text. The unused
|
||||||
|
`confidence` field has been removed and is rejected as an unknown field.
|
||||||
|
|
||||||
|
Existing operator-owned Markdown files remain valid. Existing workspace trees
|
||||||
|
are ignored rather than migrated or deleted. Distributor continues to receive
|
||||||
|
the completed Markdown report, but its source is now the selected operator
|
||||||
|
output rather than a managed report copy.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Before replacing `v0.9.0`:
|
||||||
|
|
||||||
|
1. Remove `workspace:` and `recent_change:` from configuration files.
|
||||||
|
2. Give scheduled commands a predictable working directory or explicit
|
||||||
|
`--out` or `--out-dir` destination. Confirm that these selected files may be
|
||||||
|
atomically replaced on later successful runs.
|
||||||
|
3. Remove historical `inspect` invocations and update action-summary consumers
|
||||||
|
to use `outputPath` and the remaining active-workflow fields.
|
||||||
|
4. Decide whether old workspace contents have any external retention value.
|
||||||
|
Weatherreporter no longer reads them; after review, they may be removed
|
||||||
|
manually using the narrowly scoped procedure in the operations guide.
|
||||||
|
5. Review Promptkit profile selection and credentials. Hourly defaults to
|
||||||
|
`weather-light`; Daily, Today, and Tomorrow default to `weather-balanced`.
|
||||||
|
A configured `promptkit.profile` still overrides every report in one action.
|
||||||
|
|
||||||
|
The embedded logical profiles are:
|
||||||
|
|
||||||
|
| Profile | OpenRouter model | Default use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `weather-light` | `deepseek/deepseek-v4-flash` | Hourly |
|
||||||
|
| `weather-balanced` | `~google/gemini-flash-latest` | Daily, Today, Tomorrow |
|
||||||
|
| `weather-deep` | `~anthropic/claude-sonnet-latest` | Explicit selection |
|
||||||
|
|
||||||
|
Override a complete same-ID definition through `promptkit.profile_file` or
|
||||||
|
`promptkit.profile_dir` to use different models or a local OpenAI-compatible
|
||||||
|
endpoint. Definitions are replaced rather than field-merged, and a malformed
|
||||||
|
matching override fails instead of silently falling back.
|
||||||
|
|
||||||
|
See the [CLI reference](../cli.md), [configuration
|
||||||
|
reference](../config.md), [operations guide](../operations.md), and [Promptkit
|
||||||
|
integration](../integrations/promptkit.md) for the exact current contracts.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Stateless Execution And Operator-Owned Outputs
|
||||||
|
|
||||||
|
- Removed local forecast-change comparison, prior-snapshot selection, durable
|
||||||
|
module and prompt artifacts, managed reports, metadata compatibility, run
|
||||||
|
discovery, notification receipts, and the complete `internal/state`
|
||||||
|
subsystem.
|
||||||
|
- Added an Accepted architecture decision recording the stateless
|
||||||
|
transformation pipeline and operator-owned output boundary.
|
||||||
|
- Kept weather, facts, modules, prompt input, generated text, and render context
|
||||||
|
in memory during ordinary execution.
|
||||||
|
- Made output publication atomic and ensured cancellation or deadline expiry
|
||||||
|
observed before publication leaves an existing destination unchanged.
|
||||||
|
- Added complete batch-destination preflight before the first report prompt,
|
||||||
|
so a structural collision cannot leave an unreported partial batch.
|
||||||
|
- Preserved successful outputs after report or Distributor failure. Batch
|
||||||
|
notification runs only after every selected report succeeds.
|
||||||
|
|
||||||
|
### Promptkit Profiles And Prompt Contracts
|
||||||
|
|
||||||
|
- Upgraded Promptkit from `v0.4.0` to `v0.5.0`.
|
||||||
|
- Added embedded `weather-light`, `weather-balanced`, and `weather-deep`
|
||||||
|
profiles and mapped each exact prompt to its logical default.
|
||||||
|
- Added embedded-profile fallback after configured `profile_file` or
|
||||||
|
`profile_dir` lookup, allowing operators to replace a logical profile without
|
||||||
|
changing report definitions.
|
||||||
|
- Added a maintained local-endpoint example for replacing `weather-light`.
|
||||||
|
- Advanced the four prompt definitions to `2.0.0` and the curated data package
|
||||||
|
to v4 after removing Recent Changes.
|
||||||
|
- Required `precipitation_timing`, normalized whitespace-only timing to an
|
||||||
|
empty string, and removed the unused confidence value.
|
||||||
|
|
||||||
|
### CLI, Reliability, Documentation, And Testing
|
||||||
|
|
||||||
|
- Simplified action summaries to active workflow identity, output, model,
|
||||||
|
validation, warning, debug, notification, and safe error information.
|
||||||
|
- Made batch counters report-only while retaining failed action status and
|
||||||
|
non-zero exit behavior for batch notification failure.
|
||||||
|
- Kept prompt and profile inspection ahead of weather collection and validated
|
||||||
|
every batch candidate before collecting once.
|
||||||
|
- Replaced state-oriented workflow fixtures with focused generation, batch,
|
||||||
|
output, cancellation, profile-resolution, Distributor, and CLI coverage.
|
||||||
|
- Reconciled user, operator, integration, internal, policy, and ADR
|
||||||
|
documentation around the implemented stateless architecture and removed
|
||||||
|
completed temporary roadmaps.
|
||||||
33
docs/releases/v0.10.1.md
Normal file
33
docs/releases/v0.10.1.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Weatherreporter v0.10.1
|
||||||
|
|
||||||
|
This release repairs release validation after the `v0.10.0` pipeline failed in
|
||||||
|
its privileged build container. Application behavior is unchanged from
|
||||||
|
`v0.10.0`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The unreadable-secret configuration test now verifies that its process is
|
||||||
|
actually subject to file permission bits before asserting that a mode-`000`
|
||||||
|
file cannot be read. This keeps the test meaningful for ordinary users while
|
||||||
|
allowing the release suite to run correctly in privileged containers.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This patch release makes no changes to Weatherreporter's CLI, configuration,
|
||||||
|
report output, integrations, prompts, profiles, or operating behavior. It is
|
||||||
|
fully compatible with `v0.10.0`.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special operator action is required. Use `v0.10.1` in place of `v0.10.0`;
|
||||||
|
the `v0.10.0` tag remains immutable, but its failed pipeline did not publish
|
||||||
|
release binaries.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Made the unreadable-secret test capability-aware when the test process can
|
||||||
|
bypass filesystem permission bits.
|
||||||
|
- Preserved the production contract that genuinely unreadable secret files
|
||||||
|
fail configuration loading.
|
||||||
|
- Restored portable release validation in Woodpecker's privileged Go
|
||||||
|
container.
|
||||||
37
docs/releases/v0.11.0.md
Normal file
37
docs/releases/v0.11.0.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Weatherreporter v0.11.0
|
||||||
|
|
||||||
|
This release adds a configurable default publication directory for generated
|
||||||
|
weather reports.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Operators can now set `output.directory` once for both individual reports and
|
||||||
|
scheduled batches. Explicit `--out` and `--out-dir` destinations continue to
|
||||||
|
take precedence, while installations that omit the setting retain the existing
|
||||||
|
current-directory behavior.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This release is additive and compatible with `v0.10.1`. Existing configuration
|
||||||
|
files, commands, report filenames, Promptkit behavior, and Distributor
|
||||||
|
notification behavior remain valid and unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special action is required. To use the new default destination, configure
|
||||||
|
`output.directory` as described in the [configuration
|
||||||
|
reference](../config.md). Existing deployments may continue using the current
|
||||||
|
working directory or explicit CLI output flags.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added strict configuration loading and validation for the optional
|
||||||
|
`output.directory` field.
|
||||||
|
- Applied the configured directory consistently to `generate` and `run`, with
|
||||||
|
explicit CLI destinations retaining highest precedence.
|
||||||
|
- Preserved relative-path handling, absolute result paths, atomic publication,
|
||||||
|
cancellation safety, and Distributor notification ordering.
|
||||||
|
- Strengthened output preflight so existing non-directory paths, uninspectable
|
||||||
|
paths, and dangling symlink components fail before expensive report work.
|
||||||
|
- Updated the [CLI reference](../cli.md) and [operations
|
||||||
|
guide](../operations.md) for the new destination-selection behavior.
|
||||||
66
docs/releases/v0.12.0.md
Normal file
66
docs/releases/v0.12.0.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Weatherreporter v0.12.0
|
||||||
|
|
||||||
|
This release completes a repository-wide correctness, security, efficiency,
|
||||||
|
test-durability, and documentation audit.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Weatherreporter now applies stricter validation and bounded diagnostics across
|
||||||
|
its configuration, weather collection, Promptkit, rendering, publication,
|
||||||
|
comparison, and Distributor boundaries. Report preparation and execution carry
|
||||||
|
one reconciled identity, independent weather sources are collected
|
||||||
|
concurrently, and cancellation preserves completed report and comparison
|
||||||
|
outcomes.
|
||||||
|
|
||||||
|
The release also removes obsolete compatibility surfaces and consolidates
|
||||||
|
duplicated implementation and test policy without changing ordinary report
|
||||||
|
commands or output identities.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This release is compatible with `v0.11.0` for ordinary `generate`, `run`, and
|
||||||
|
`compare` commands, configuration files, report filenames, comparison bundles,
|
||||||
|
and Distributor integration.
|
||||||
|
|
||||||
|
Sensitive prompt-debug capture through `--llm-debug-dir` is now supported only
|
||||||
|
on Unix hosts. Non-Unix hosts reject an explicit capture request before prompt
|
||||||
|
inspection, weather collection, or provider execution because the required
|
||||||
|
handle-relative, no-follow filesystem guarantees are unavailable there.
|
||||||
|
|
||||||
|
Several unused internal compatibility exports were removed. They were not part
|
||||||
|
of the documented CLI, configuration, artifact, or integration contracts.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special action is required for ordinary installations. Operators who use
|
||||||
|
`--llm-debug-dir` on Windows must run that diagnostic workflow on a Unix host.
|
||||||
|
Review any automation that depended on undocumented internal Go APIs removed by
|
||||||
|
this release.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Hardened configuration loading, source validation, secrets rollback,
|
||||||
|
endpoint validation, HTTP diagnostics, generated-text limits, prompt-debug
|
||||||
|
redaction, output publication, comparison replacement, and Distributor
|
||||||
|
failure reporting.
|
||||||
|
- Reconciled inspected, prepared, callback, and completed Promptkit identity
|
||||||
|
and provenance before accepting generated content.
|
||||||
|
- Preserved metric values, civil-day and daypart identity, overnight alerts,
|
||||||
|
precipitation semantics, and Markdown structure across deterministic report
|
||||||
|
preparation and rendering.
|
||||||
|
- Collected independent Weather API sources concurrently and reused readiness
|
||||||
|
data while retaining deterministic normalized results.
|
||||||
|
- Preserved completed report and comparison failures independently from shared
|
||||||
|
cancellation, stopped unfinished work, and skipped batch notification after
|
||||||
|
cancellation or partial report failure.
|
||||||
|
- Made secure prompt-debug traversal descriptor-relative on Unix and fail
|
||||||
|
closed elsewhere. See the [operations
|
||||||
|
guide](../operations.md#optional-prompt-debug-capture).
|
||||||
|
- Strengthened default test portability and determinism, including
|
||||||
|
capability-aware symbolic-link fixtures and platform-appropriate process
|
||||||
|
signal coverage.
|
||||||
|
- Removed obsolete compatibility helpers, duplicated test ownership, dormant
|
||||||
|
persistence code, and completed audit and implementation roadmaps.
|
||||||
|
- Updated the [architecture policy](../policy/architecture.md), [testing
|
||||||
|
policy](../policy/testing.md), and focused internal guides to describe the
|
||||||
|
implemented final state.
|
||||||
@@ -3,6 +3,40 @@
|
|||||||
This roadmap contains future work only. Each section identifies its planning
|
This roadmap contains future work only. Each section identifies its planning
|
||||||
status; current behavior is documented outside `docs/roadmap/`.
|
status; current behavior is documented outside `docs/roadmap/`.
|
||||||
|
|
||||||
|
## Upstream Forecast Change Product
|
||||||
|
|
||||||
|
Status: Proposed upstream feature request; unimplemented.
|
||||||
|
|
||||||
|
Weatherreporter's local Recent Changes feature was removed by the accepted
|
||||||
|
[stateless execution decision](../adr/0001-stateless-execution.md). Forecast
|
||||||
|
version history and comparison are better owned by the Weather API, where the
|
||||||
|
underlying forecast issuances can be retained and compared consistently for
|
||||||
|
all consumers.
|
||||||
|
|
||||||
|
A future Weather API feature should expose a structured change product with:
|
||||||
|
|
||||||
|
- explicit current and baseline forecast issuance timestamps or identifiers;
|
||||||
|
- documented baseline selection, such as a requested comparison timestamp,
|
||||||
|
preceding issuance, or fixed rolling period;
|
||||||
|
- location, timezone, and half-open valid-period identity;
|
||||||
|
- typed changed values with previous and current values and units;
|
||||||
|
- stable change categories for temperature, precipitation probability and
|
||||||
|
timing, wind gusts, alerts, and aggregate hazards;
|
||||||
|
- an API-owned significance classification or enough structured information
|
||||||
|
for a stateless consumer to apply a documented presentation threshold; and
|
||||||
|
- deterministic ordering, missing-baseline behavior, and source metadata.
|
||||||
|
|
||||||
|
The API should compare forecast versions, not track a Weatherreporter client's
|
||||||
|
"previous run." It should not require consumer identity, mutable cursors, or
|
||||||
|
Weatherreporter-managed history. A missing baseline should be a normal empty
|
||||||
|
result rather than an error.
|
||||||
|
|
||||||
|
Once a stable upstream contract exists, a separate Weatherreporter roadmap may
|
||||||
|
reintroduce change commentary by collecting that product and mapping it into a
|
||||||
|
curated prompt-facing module. There must be no local snapshot fallback. The
|
||||||
|
ordinary Weatherreporter process must remain stateless, and the upstream
|
||||||
|
feature should have deterministic fixtures before adoption.
|
||||||
|
|
||||||
## Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Status: Proposed and unimplemented.
|
Status: Proposed and unimplemented.
|
||||||
@@ -13,8 +47,10 @@ Possible direction:
|
|||||||
|
|
||||||
1. Detect candidate storm events from alerts, forecast discussion, weather
|
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||||
story context, hourly thresholds, and material forecast changes.
|
story context, hourly thresholds, and material forecast changes.
|
||||||
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
2. Evaluate candidates through Promptkit or another narrow evaluator adapter.
|
||||||
3. Persist storm lifecycle state.
|
3. Keep any required storm lifecycle state in the upstream service or another
|
||||||
|
explicitly designed external owner rather than silently reintroducing a
|
||||||
|
Weatherreporter workspace.
|
||||||
4. Generate or update a storm report only when a meaningful event is present.
|
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.
|
||||||
|
|
||||||
@@ -54,8 +90,7 @@ Status: Proposed and unimplemented.
|
|||||||
Possible future modules:
|
Possible future modules:
|
||||||
|
|
||||||
- `hourly_table` for compact valid-period hourly facts
|
- `hourly_table` for compact valid-period hourly facts
|
||||||
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
- `forecast_delta` after an upstream forecast-change product exists
|
||||||
Changes
|
|
||||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
deterministic stanza
|
deterministic stanza
|
||||||
- `storm_window_summary` if manual or automatic storm reports need a dedicated
|
- `storm_window_summary` if manual or automatic storm reports need a dedicated
|
||||||
@@ -79,7 +114,7 @@ contracts](../internal/facts.md), [module internals](../internal/module.md), and
|
|||||||
- keep broad reusable calculations in `DerivedFacts`
|
- keep broad reusable calculations in `DerivedFacts`
|
||||||
- keep prompt-facing field shape inside module builders
|
- keep prompt-facing field shape inside module builders
|
||||||
- use typed options for configurable module behavior
|
- use typed options for configurable module behavior
|
||||||
- keep module snapshots structured and deterministic for Recent Changes
|
- keep module output structured and deterministic
|
||||||
|
|
||||||
## Distributor Notification Enhancements
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
@@ -92,10 +127,8 @@ behavior is documented in the [Distributor adapter guide](../internal/distributo
|
|||||||
unimplemented:
|
unimplemented:
|
||||||
|
|
||||||
- `failure_policy: warn`
|
- `failure_policy: warn`
|
||||||
- uploading metadata, module snapshots, data packages, or preflight artifacts
|
|
||||||
- durable upload retry queues
|
- durable upload retry queues
|
||||||
- distributor-specific CLI flags
|
- distributor-specific CLI flags
|
||||||
- distributor workspace scanning
|
|
||||||
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||||
layout inside weatherreporter
|
layout inside weatherreporter
|
||||||
|
|
||||||
@@ -103,6 +136,17 @@ Any distributor enhancement should preserve the adapter boundary:
|
|||||||
weatherreporter selects explicit generated files and submits source bundles,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
|
## Comparison Profile Diagnostics
|
||||||
|
|
||||||
|
Status: Proposed and unimplemented.
|
||||||
|
|
||||||
|
Comparison preflight failures could identify the profile being inspected and
|
||||||
|
preserve a safe, actionable Promptkit cause, such as a duplicate profile ID,
|
||||||
|
instead of reporting only a generic `profile_load` failure. Any improvement
|
||||||
|
must continue to omit credentials, endpoints, and other sensitive profile
|
||||||
|
values. Regression coverage should include a comparison that mixes built-in
|
||||||
|
and configured-directory profiles and a directory containing duplicate IDs.
|
||||||
|
|
||||||
## Alternate Runtime Integrations
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
Status: Proposed and unimplemented.
|
Status: Proposed and unimplemented.
|
||||||
@@ -138,6 +182,7 @@ maintenance costs make the added abstraction worthwhile:
|
|||||||
- global test helper package
|
- global test helper package
|
||||||
- logging subsystem
|
- logging subsystem
|
||||||
|
|
||||||
Any future implementation should preserve the existing public CLI, artifact
|
Any future implementation should preserve the public CLI, report-output
|
||||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
contract, report identities, module boundaries, and adapter boundaries in
|
||||||
separate roadmap explicitly changes them.
|
effect when that work begins unless a separate roadmap explicitly changes
|
||||||
|
them.
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
# 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,516 +0,0 @@
|
|||||||
# Promptkit Migration Roadmap
|
|
||||||
|
|
||||||
Status: Completed roadmap record.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap records the scope, decisions, and completed outcome of replacing
|
|
||||||
the external Scriptorium CLI integration with Promptkit. Canonical
|
|
||||||
documentation outside `docs/roadmap/` owns the implemented behavior.
|
|
||||||
|
|
||||||
## Pre-Migration Baseline
|
|
||||||
|
|
||||||
Status: Historical migration input.
|
|
||||||
|
|
||||||
Before the migration, Weatherreporter exposed seven report definitions, but
|
|
||||||
only four had complete prompt-backed report implementations:
|
|
||||||
|
|
||||||
- Daily Report: `weather.daily_generated_text`
|
|
||||||
- Today Report: `weather.today_generated_text`
|
|
||||||
- Tomorrow Report: `weather.tomorrow_generated_text`
|
|
||||||
- Hourly Report: `weather.hourly_generated_text`
|
|
||||||
|
|
||||||
The three-day, weekend, and storm commands and registry definitions had no
|
|
||||||
corresponding Scriptorium prompt or schema and never formed complete
|
|
||||||
operational report products. The `weather.daily_report` Scriptorium prompt was
|
|
||||||
legacy source material and was not selected by the registry.
|
|
||||||
|
|
||||||
The Scriptorium source corpus was retained temporarily under
|
|
||||||
`docs/roadmap/scriptorium/` as migration input. It contained the four
|
|
||||||
operational generated-text prompt definitions, their referenced content,
|
|
||||||
private response schemas, shared instructions, and the unused legacy Daily
|
|
||||||
Markdown prompt. The temporary corpus was removed after the runtime assets
|
|
||||||
were reconciled and embedded.
|
|
||||||
|
|
||||||
## Implemented End State
|
|
||||||
|
|
||||||
Status: Completed.
|
|
||||||
|
|
||||||
Weatherreporter pins
|
|
||||||
`gitea.maximumdirect.net/eric/promptkit` at `v0.4.0` and uses it as the
|
|
||||||
in-process engine for prompt inspection, prepared execution, provider calls,
|
|
||||||
and first-pass output validation.
|
|
||||||
|
|
||||||
The `scriptorium` executable, subprocess adapter, configuration, runtime
|
|
||||||
dependency, direct-Markdown execution path, and integration documentation have
|
|
||||||
been removed. The four operational reports continue to use structured
|
|
||||||
generated text followed by weatherreporter-owned validation and Markdown
|
|
||||||
templates.
|
|
||||||
|
|
||||||
The unfinished three-day, weekend, and storm reports are not implemented as
|
|
||||||
part of this migration. Their incomplete CLI, registry, documentation, and
|
|
||||||
generation declarations are removed from the implemented surface before the
|
|
||||||
migration is considered complete. Any future implementation of those products
|
|
||||||
requires separate roadmap scope, prompt and schema design, tests, and
|
|
||||||
documentation.
|
|
||||||
|
|
||||||
Weather selection, forecast derivation, valid periods, module construction,
|
|
||||||
Recent Changes, generated-text interpretation, Markdown templates, durable
|
|
||||||
state, inspection, output copies, and Distributor notification remain owned by
|
|
||||||
weatherreporter.
|
|
||||||
|
|
||||||
The four report prompts and private response schemas are versioned embedded
|
|
||||||
application assets. Operators configure Promptkit profiles without replacing
|
|
||||||
the report-owned corpus. One Promptkit engine is constructed per CLI
|
|
||||||
invocation and shared by every report in that invocation, including all
|
|
||||||
reports in a morning or evening batch.
|
|
||||||
|
|
||||||
Promptkit is isolated behind a weatherreporter-owned execution contract.
|
|
||||||
Promptkit request, result, validation, error, profile, backend, and provider
|
|
||||||
types do not leak into application orchestration, report definitions, domain
|
|
||||||
packages, CLI summaries, durable state contracts, or Distributor behavior.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
Status: Completed migration outcomes.
|
|
||||||
|
|
||||||
- Removed the Scriptorium runtime dependency and subprocess boundary.
|
|
||||||
- Migrated the four operational report prompts to Promptkit `v0.4.0`.
|
|
||||||
- Used prepared execution to persist preparation provenance before provider work
|
|
||||||
while executing the exact frozen snapshot.
|
|
||||||
- Validated report prompt and profile selections before weather collection when
|
|
||||||
the required information is available.
|
|
||||||
- Preserved deterministic module snapshots and structured Recent Changes.
|
|
||||||
- Preserved generated-text domain validation and repository-owned Markdown
|
|
||||||
rendering.
|
|
||||||
- Preserved context cancellation, actionable errors, secret redaction, and
|
|
||||||
inspectable failures.
|
|
||||||
- Improved durable prompt provenance with prompt, input, profile, model,
|
|
||||||
validation, usage, and timing metadata.
|
|
||||||
- Kept content-rich prompt and response diagnostics separate from routine
|
|
||||||
metadata and CLI output.
|
|
||||||
- Kept tests offline and deterministic through injected Promptkit model
|
|
||||||
clients and fixtures.
|
|
||||||
- Removed incomplete report declarations from the implemented product surface
|
|
||||||
rather than creating new report products during an integration migration.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
Status: Completed migration constraints.
|
|
||||||
|
|
||||||
The completed migration did not:
|
|
||||||
|
|
||||||
- create prompts, schemas, templates, or completed products for three-day,
|
|
||||||
weekend, or storm reports;
|
|
||||||
- preserve the unused `weather.daily_report` legacy Markdown prompt as an
|
|
||||||
active runtime asset;
|
|
||||||
- preserve a direct-Markdown LLM generation mode;
|
|
||||||
- move meteorological selection, derivation, thresholds, or comparison logic
|
|
||||||
into prompts or Promptkit;
|
|
||||||
- send raw unbounded Weather API responses to the model;
|
|
||||||
- replace weatherreporter's generated-text domain validation or Markdown
|
|
||||||
template rendering;
|
|
||||||
- add a general workflow engine, provider plugin system, or arbitrary backend
|
|
||||||
registry;
|
|
||||||
- add automatic provider, validation, repair, or capacity retries;
|
|
||||||
- add concurrent report generation to the sequential batch workflow;
|
|
||||||
- expose Promptkit types as weatherreporter contracts;
|
|
||||||
- keep a production-selectable Scriptorium/Promptkit dual-run mode;
|
|
||||||
- require Promptkit eager source validation, structured generation errors, or
|
|
||||||
semantic execution-target fingerprints; or
|
|
||||||
- use an unpublished Promptkit commit, committed Go workspace, or committed
|
|
||||||
local module replacement.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
Status: Implemented migration decisions.
|
|
||||||
|
|
||||||
### Dependency And Upgrade Boundary
|
|
||||||
|
|
||||||
- The migration pins the tagged Promptkit `v0.4.0` release.
|
|
||||||
- Coordinated local development may temporarily use the sibling Promptkit
|
|
||||||
checkout, but committed module metadata must reference the tagged release.
|
|
||||||
- The adapter relies on the public root Promptkit package only.
|
|
||||||
- A future Promptkit upgrade requires explicit review of prepared-execution
|
|
||||||
lifecycle, prompt and profile inspection, prompt/profile/schema formats,
|
|
||||||
error identities, validation behavior, capacity behavior, and the outbound
|
|
||||||
provider contract.
|
|
||||||
- Promptkit's deferred eager source validation, structured generation errors,
|
|
||||||
and semantic execution-target fingerprints do not block this migration.
|
|
||||||
|
|
||||||
### Operational Report Scope
|
|
||||||
|
|
||||||
- The migration preserves these prompt IDs:
|
|
||||||
`weather.daily_generated_text`, `weather.today_generated_text`,
|
|
||||||
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`.
|
|
||||||
- Each operational report definition selects the exact embedded prompt version
|
|
||||||
`1.0.0`; execution does not rely on ambiguous single-version lookup.
|
|
||||||
- Morning and evening batch membership remains based on Today, Tomorrow, and
|
|
||||||
eligible future Daily reports.
|
|
||||||
- Three-day, weekend, and storm are removed from current CLI help, parsing,
|
|
||||||
report registry membership, tests that claim implemented generation, and
|
|
||||||
non-roadmap documentation.
|
|
||||||
- The future product concepts may remain under `docs/roadmap/`, but migration
|
|
||||||
verification does not invent outputs or compare nonexistent prompts.
|
|
||||||
|
|
||||||
### Application Boundary
|
|
||||||
|
|
||||||
- Promptkit remains an adapter boundary even though it runs in process.
|
|
||||||
- A weatherreporter-owned contract represents prompt identity, preparation,
|
|
||||||
execution, output, validation, usage, provenance, and neutral error
|
|
||||||
categories.
|
|
||||||
- The Promptkit adapter maps public Promptkit values into that contract.
|
|
||||||
- App orchestration and test fakes depend on the project-owned contract, not
|
|
||||||
Promptkit.
|
|
||||||
- Scriptorium-specific request, result, error, and generation-mode types are
|
|
||||||
removed rather than renamed and retained.
|
|
||||||
|
|
||||||
### Prompt And Schema Ownership
|
|
||||||
|
|
||||||
- Weatherreporter embeds the four operational prompt definitions, referenced
|
|
||||||
prompt content, shared prompt content, and private response schemas.
|
|
||||||
- Assets remain separate files rather than inline Go strings.
|
|
||||||
- The temporary corpus under `docs/roadmap/scriptorium/` is migration source
|
|
||||||
material, not the final runtime location.
|
|
||||||
- Weatherreporter's existing generated-text domain types, schemas, and
|
|
||||||
templates remain the canonical application contract. Imported Scriptorium
|
|
||||||
assets are reconciled with that contract rather than copied blindly or kept
|
|
||||||
as duplicate runtime schemas.
|
|
||||||
- The imported Daily schema's incorrect Today `$id` and title are corrected.
|
|
||||||
- `confidence` is handled consistently across each prompt, provider-facing
|
|
||||||
schema, generated-text domain type, and template. The existing optional
|
|
||||||
weatherreporter field remains supported unless a separate domain decision
|
|
||||||
removes it.
|
|
||||||
- Prompt input metadata identifies the serialized data package as YAML rather
|
|
||||||
than JSON.
|
|
||||||
- Imported `pipeline-weather/...` schema paths are replaced with paths valid
|
|
||||||
inside the embedded Promptkit schema source.
|
|
||||||
- Imported `repair_attempts: 2` values are removed or set to zero. The
|
|
||||||
migration does not rely on Promptkit's internal-only repair capability.
|
|
||||||
- The unused `weather.daily_report` prompt is not promoted into runtime assets.
|
|
||||||
- One centralized embedded prompt/schema source is sufficient; Weatherreporter
|
|
||||||
does not need Notarius's multi-module asset-flattening registry.
|
|
||||||
|
|
||||||
### Profiles, Backends, And Credentials
|
|
||||||
|
|
||||||
- Execution profiles remain operator-configurable rather than embedded report
|
|
||||||
policy.
|
|
||||||
- Each embedded operational prompt declares Promptkit's built-in
|
|
||||||
`gemini-flash-latest` profile as its default.
|
|
||||||
- `gemini-flash-latest` is intentionally a moving model alias. The execution
|
|
||||||
record captures the effective model identity, but operators who require a
|
|
||||||
pinned model must select an explicit external profile.
|
|
||||||
- Configuration supports at most one external profile source:
|
|
||||||
`promptkit.profile_file` or `promptkit.profile_dir`. The two fields are
|
|
||||||
mutually exclusive.
|
|
||||||
- A nonblank `promptkit.profile` is the explicit request profile for every
|
|
||||||
report in the invocation and takes precedence over each prompt's
|
|
||||||
`default_profile`. A blank value uses the prompt default.
|
|
||||||
- Promptkit's normal profile-source precedence remains intact: an external
|
|
||||||
matching profile takes precedence over an embedded built-in profile, and an
|
|
||||||
invalid matching external profile is an error rather than a reason to fall
|
|
||||||
back.
|
|
||||||
- Weatherreporter exposes Promptkit's conventional `local` backend through the
|
|
||||||
narrow `promptkit.local.endpoint` and
|
|
||||||
`promptkit.local.concurrency_limit` configuration fields. It does not expose
|
|
||||||
arbitrary backend registration.
|
|
||||||
- A configured local endpoint registers the engine-scoped `local` backend. An
|
|
||||||
operator-supplied external profile selects it with `backend: local` and owns
|
|
||||||
the model-specific settings; Weatherreporter does not invent a local model
|
|
||||||
profile.
|
|
||||||
- Local concurrency defaults to one. A value of zero means unlimited, matching
|
|
||||||
Promptkit, and a negative value is invalid. Queue capacity and general
|
|
||||||
backend parameters are not exposed.
|
|
||||||
- Credential values remain in environment variables or file-backed
|
|
||||||
environment secrets. Configuration contains only credential source names.
|
|
||||||
- Provider credentials never appear in logs, errors, CLI output, durable
|
|
||||||
metadata, preparation records, execution records, or debug summaries.
|
|
||||||
- Promptkit `InspectProfile` reports structural target and credential
|
|
||||||
requirements; Weatherreporter owns policy for checking configured
|
|
||||||
environment availability.
|
|
||||||
- Promptkit revalidates environment credentials at `RunPrepared`; a successful
|
|
||||||
preparation does not promise that execution-time credentials remain
|
|
||||||
available.
|
|
||||||
|
|
||||||
### Configuration Contract
|
|
||||||
|
|
||||||
The replacement configuration surface is:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
promptkit:
|
|
||||||
profile: ""
|
|
||||||
profile_file: ""
|
|
||||||
profile_dir: ""
|
|
||||||
timeout: 2m
|
|
||||||
|
|
||||||
local:
|
|
||||||
endpoint: ""
|
|
||||||
concurrency_limit: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
- `timeout` remains the transport-wide provider-call safety cap.
|
|
||||||
- A blank local endpoint leaves the conventional local backend unregistered.
|
|
||||||
- Scriptorium's `binary`, `config_path`, and `extra_args` settings have no
|
|
||||||
Promptkit equivalents and are removed.
|
|
||||||
- Configuration validation rejects simultaneous `profile_file` and
|
|
||||||
`profile_dir` values, invalid local endpoints, negative concurrency, and
|
|
||||||
selected profiles that cannot resolve their backend.
|
|
||||||
|
|
||||||
### Engine Construction And Inspection
|
|
||||||
|
|
||||||
- One Promptkit engine is constructed per CLI invocation at the application
|
|
||||||
composition boundary.
|
|
||||||
- Single-report generation and every report in a batch use that same engine.
|
|
||||||
- Per-report orchestration does not construct a default engine.
|
|
||||||
- Promptkit backend capacity state and HTTP transport are shared consistently
|
|
||||||
for the invocation.
|
|
||||||
- Before collection, `InspectPrompt` checks every selected report's exact ID
|
|
||||||
and version, declared `data_package` input, default-profile metadata, prompt
|
|
||||||
hash availability, and declared output contract.
|
|
||||||
- `InspectPrompt` is a point-in-time structural check. It does not load a JSON
|
|
||||||
Schema, resolve a profile, or freeze later execution.
|
|
||||||
- Explicit profile overrides and relevant prompt defaults are checked with
|
|
||||||
`InspectProfile` before collection when application policy requires them.
|
|
||||||
- `InspectProfile` is also point-in-time and does not check credential values.
|
|
||||||
- Successful `PrepareExecution`, not inspection, is the per-run authority for
|
|
||||||
loaded schema, rendered content, frozen inputs, effective settings, and
|
|
||||||
durable execution provenance.
|
|
||||||
|
|
||||||
### Prompt Input
|
|
||||||
|
|
||||||
- Promptkit receives only the curated `data_package` produced by
|
|
||||||
`internal/promptinput`.
|
|
||||||
- Weatherreporter serializes the package once, atomically persists those exact
|
|
||||||
bytes, and supplies the same bytes with a Promptkit inline artifact.
|
|
||||||
- The managed data-package path may be supplied as non-secret provenance
|
|
||||||
through the inline artifact URI.
|
|
||||||
- Weatherreporter does not delegate unrestricted path loading to Promptkit's
|
|
||||||
default file artifact reader.
|
|
||||||
- Prompt inspection and adapter tests verify that `data_package` is required
|
|
||||||
and declared with the chosen YAML media type.
|
|
||||||
|
|
||||||
### Prepared Execution
|
|
||||||
|
|
||||||
- `Engine.PrepareExecution` replaces Scriptorium render preflight.
|
|
||||||
- Weatherreporter obtains `PreparedExecution.Details`, maps a safe subset into
|
|
||||||
its own preparation record, and persists that record before calling
|
|
||||||
`Engine.RunPrepared`.
|
|
||||||
- `RunPrepared` executes the frozen prompt, profile, schema, inputs, rendered
|
|
||||||
messages, target, and validation resources retained by the handle.
|
|
||||||
- Every acquired handle is followed immediately by `defer handle.Discard()`.
|
|
||||||
Discard is safe after execution and releases unused private execution state.
|
|
||||||
- Handles remain adapter-local, engine-bound, one-shot, in-process values.
|
|
||||||
They are never serialized, persisted, copied into app contracts, or treated
|
|
||||||
as restartable jobs.
|
|
||||||
- Preparation and execution use independent contexts. Execution receives the
|
|
||||||
active report workflow context.
|
|
||||||
- Capacity is not reserved during preparation. Capacity rejection can
|
|
||||||
therefore occur after a preparation record has been persisted.
|
|
||||||
- `RunPrepared` consumes the handle on success and every operational failure.
|
|
||||||
- Preparation details remain available from the adapter after execution or
|
|
||||||
discard, but rendered messages are not copied into routine durable state.
|
|
||||||
- Promptkit execution timing excludes preparation and consumer-held delay.
|
|
||||||
Weatherreporter records preparation timing and execution timing separately.
|
|
||||||
|
|
||||||
### Execution And Validation
|
|
||||||
|
|
||||||
- All four operational reports use Promptkit JSON Schema output validation.
|
|
||||||
- A completed Promptkit validation rejection returns a `RunResult`; the
|
|
||||||
adapter retains raw output and bounded validation details before failing the
|
|
||||||
report.
|
|
||||||
- An operational generation or validation error returns no partial
|
|
||||||
`RunResult`.
|
|
||||||
- Weatherreporter's `internal/generatedtext` validation remains the final
|
|
||||||
report-specific decode and domain boundary.
|
|
||||||
- Weatherreporter's `internal/reporttemplate` remains responsible for managed
|
|
||||||
Markdown rendering.
|
|
||||||
- Weatherreporter atomically persists Promptkit raw output and later artifacts
|
|
||||||
rather than asking Promptkit to choose managed filesystem paths.
|
|
||||||
- No Promptkit output-repair behavior is assumed or requested.
|
|
||||||
|
|
||||||
## Durable Artifacts And Observability
|
|
||||||
|
|
||||||
Status: Implemented design constraints.
|
|
||||||
|
|
||||||
Routine durable state retains useful non-secret provenance without persisting
|
|
||||||
full rendered prompts.
|
|
||||||
|
|
||||||
The preparation record contains:
|
|
||||||
|
|
||||||
- prompt ID and exact version;
|
|
||||||
- prompt definition hash;
|
|
||||||
- rendered prompt hash;
|
|
||||||
- input hashes;
|
|
||||||
- selected profile and backend identity;
|
|
||||||
- effective model identity;
|
|
||||||
- output contract summary;
|
|
||||||
- preparation start, end, and duration; and
|
|
||||||
- the path of the exact persisted data package.
|
|
||||||
|
|
||||||
The execution record and run metadata contain, when available:
|
|
||||||
|
|
||||||
- Promptkit run ID;
|
|
||||||
- prompt ID, version, and hashes;
|
|
||||||
- input hashes;
|
|
||||||
- selected profile, backend, and model identity;
|
|
||||||
- generated-content hash;
|
|
||||||
- token usage;
|
|
||||||
- execution start, end, and duration;
|
|
||||||
- validation status and bounded diagnostics; and
|
|
||||||
- paths of separately persisted raw output, normalized generated text, render
|
|
||||||
context, managed Markdown, and other artifacts reached by the workflow.
|
|
||||||
|
|
||||||
Provider endpoints, full effective model parameter maps, rendered messages,
|
|
||||||
schema bodies, data-package contents, and generated content do not belong in
|
|
||||||
routine metadata or CLI summaries.
|
|
||||||
|
|
||||||
Rendered messages and other content-rich preparation or response diagnostics
|
|
||||||
are available only when the operator supplies
|
|
||||||
`--llm-debug-dir <path>` to a single-report or batch command.
|
|
||||||
|
|
||||||
- There is no persistent YAML setting for debug capture.
|
|
||||||
- The debug root is validated or created before weather collection or provider
|
|
||||||
work. A requested destination that cannot be secured or written is an error.
|
|
||||||
- Artifacts are grouped beneath
|
|
||||||
`<path>/<report-id>/<valid-date>/<run-id>/`.
|
|
||||||
- Directories and files use owner-only permissions and atomic writes.
|
|
||||||
- Debug artifacts may contain rendered messages and content-rich preparation
|
|
||||||
or response diagnostics, but never credentials.
|
|
||||||
- The debug path appears in command output only when debug capture is enabled;
|
|
||||||
it is not added to routine durable metadata.
|
|
||||||
- Debug artifacts are not cache or comparison inputs. Their retention is owned
|
|
||||||
by the operator who selected the directory.
|
|
||||||
|
|
||||||
### Artifact Identities And Versions
|
|
||||||
|
|
||||||
Weatherreporter replaces Scriptorium-specific artifact identities rather than
|
|
||||||
reusing names whose meanings have changed:
|
|
||||||
|
|
||||||
- `PromptPreparationArtifact` uses schema version
|
|
||||||
`weatherreporter.prompt_preparation.v1`, is written as
|
|
||||||
`prompt_preparation.<runID>.json`, and is referenced by
|
|
||||||
`preparationPath`.
|
|
||||||
- `PromptExecutionArtifact` uses schema version
|
|
||||||
`weatherreporter.prompt_execution.v1`, is written as
|
|
||||||
`prompt_execution.<runID>.json`, and is referenced by `executionPath`.
|
|
||||||
- Run metadata advances to `weatherreporter.metadata.v2` and uses those new
|
|
||||||
path fields.
|
|
||||||
|
|
||||||
Preparation files remain beneath the existing configurable `preflight/`
|
|
||||||
directory, and execution files remain beneath the existing `snapshots/` tree.
|
|
||||||
The stable physical grouping limits deployment disruption without preserving
|
|
||||||
misleading Scriptorium-era filenames or field names. Raw generated output,
|
|
||||||
normalized generated text, render context, managed Markdown, and other
|
|
||||||
artifacts whose meanings have not changed retain their existing names and
|
|
||||||
locations.
|
|
||||||
|
|
||||||
Run inspection remains able to read `weatherreporter.metadata.v1` and its
|
|
||||||
legacy `preflightPath` and `generatedTextResultPath` references. New runs write
|
|
||||||
only the v2 metadata and new artifact names; Weatherreporter does not
|
|
||||||
dual-write deprecated aliases. CLI summary fields adopt `preparationPath` and
|
|
||||||
`executionPath` as an explicit, documented contract change.
|
|
||||||
|
|
||||||
## Failure Contract
|
|
||||||
|
|
||||||
Status: Implemented design constraints.
|
|
||||||
|
|
||||||
- A preparation failure produces a redacted weatherreporter-owned failure
|
|
||||||
receipt with report, RunID, prompt, stage, timing, and classified error
|
|
||||||
context. It does not fabricate Promptkit preparation details.
|
|
||||||
- An operational execution failure retains the successful preparation record
|
|
||||||
and adds a redacted execution failure receipt. No partial Promptkit result or
|
|
||||||
model output is invented.
|
|
||||||
- A Promptkit validation rejection retains the returned result, raw generated
|
|
||||||
output, validation details, and safe provenance before the report fails.
|
|
||||||
- A later generated-text decode, domain-validation, or template failure
|
|
||||||
retains every raw and validated artifact reached before that stage.
|
|
||||||
- Caller cancellation takes precedence when the active workflow context is
|
|
||||||
canceled.
|
|
||||||
- `promptkit.CapacityError` is recognized with `errors.As`; its backend ID is
|
|
||||||
copied into a weatherreporter-owned capacity error while
|
|
||||||
`ErrCapacityExceeded` remains the classification.
|
|
||||||
- Capacity rejection is an operational report failure, not invalid model
|
|
||||||
output, and does not trigger an automatic retry.
|
|
||||||
- Other Promptkit public error identities are translated into the narrow
|
|
||||||
weatherreporter error categories needed by CLI, metadata, and batch
|
|
||||||
behavior. Diagnostic prose is not parsed as a contract.
|
|
||||||
- Single-report commands return the classified failure with available
|
|
||||||
inspectable paths.
|
|
||||||
- Batch runs continue independent later reports under the existing batch
|
|
||||||
failure policy.
|
|
||||||
- Any future retry policy belongs to app orchestration, not the adapter.
|
|
||||||
|
|
||||||
## Compatibility Requirements
|
|
||||||
|
|
||||||
Status: Implemented design constraints.
|
|
||||||
|
|
||||||
- Daily, Today, Tomorrow, and Hourly report IDs, prompt IDs, valid periods,
|
|
||||||
artifact grouping, output names, and Distributor bundle behavior remain
|
|
||||||
stable.
|
|
||||||
- Morning and evening batch collection, planning, ordering, and continuation
|
|
||||||
behavior remains stable.
|
|
||||||
- Module snapshot and Recent Changes behavior remains deterministic.
|
|
||||||
- Promptkit receives only the existing curated prompt-input boundary.
|
|
||||||
- Managed Markdown remains the Distributor upload source.
|
|
||||||
- RunID lookup and inspection remain available for successful and failed runs.
|
|
||||||
- Existing managed paths remain stable where their meaning is unchanged.
|
|
||||||
Scriptorium-specific artifact names or schemas change when retaining them
|
|
||||||
would misrepresent the Promptkit contract.
|
|
||||||
- Existing v1 run metadata and referenced artifacts remain inspectable after
|
|
||||||
the migration. New runs use the v2 metadata and Promptkit-era artifact
|
|
||||||
identities without dual-writing deprecated aliases.
|
|
||||||
- Artifact or metadata schema changes are explicit, documented, and covered by
|
|
||||||
state and inspection tests.
|
|
||||||
- Prompt or generated content is not added to routine logs or CLI summaries.
|
|
||||||
- Tests do not require live providers or credentials.
|
|
||||||
- Removing incomplete three-day, weekend, and storm surfaces is documented as
|
|
||||||
correction of an unfinished product boundary, not as successful Promptkit
|
|
||||||
migration of those reports.
|
|
||||||
|
|
||||||
## Verification And Completion Criteria
|
|
||||||
|
|
||||||
Status: Completed and verified.
|
|
||||||
|
|
||||||
Completion was verified by the following outcomes:
|
|
||||||
|
|
||||||
- the four operational reports inspect, prepare, and execute through Promptkit
|
|
||||||
`v0.4.0` using embedded report-owned assets;
|
|
||||||
- every report uses exact prompt version `1.0.0`, requires the YAML
|
|
||||||
`data_package`, and declares the expected JSON Schema output contract;
|
|
||||||
- prepared execution persists a safe preparation record before provider work
|
|
||||||
and executes the same frozen snapshot;
|
|
||||||
- deterministic offline adapter and app tests cover success, preparation
|
|
||||||
failure, credential revalidation, capacity rejection, cancellation, timeout,
|
|
||||||
generation failure, Promptkit validation rejection, generated-text domain
|
|
||||||
failure, template failure, and handle discard;
|
|
||||||
- morning and evening batches construct one engine and preserve current
|
|
||||||
collection, planning, ordering, continuation, output, and notification
|
|
||||||
behavior;
|
|
||||||
- the temporary corpus has been reconciled into one runtime prompt/schema
|
|
||||||
source without duplicate provider-facing schemas;
|
|
||||||
- configuration examples load and contain no Scriptorium fields;
|
|
||||||
- CLI summaries and inspection commands expose the new project-owned artifact
|
|
||||||
contract without Promptkit types;
|
|
||||||
- Scriptorium code, configuration, tests, and runtime documentation have been
|
|
||||||
removed;
|
|
||||||
- incomplete three-day, weekend, and storm commands, registry entries, tests,
|
|
||||||
and current-behavior documentation have been removed or moved to roadmap
|
|
||||||
scope;
|
|
||||||
- non-roadmap documentation describes only the implemented Promptkit
|
|
||||||
integration;
|
|
||||||
- `go test ./...`, required focused or race-enabled checks, CLI help
|
|
||||||
validation, and `git diff --check` pass; and
|
|
||||||
- no committed `go.work`, local `replace`, live-provider test, or
|
|
||||||
secret-bearing fixture remains.
|
|
||||||
|
|
||||||
Fixture-based comparison with prior Scriptorium behavior is sufficient.
|
|
||||||
Production dual-run is not required because model calls are nondeterministic,
|
|
||||||
costly, and difficult to compare meaningfully.
|
|
||||||
|
|
||||||
## Decision Status
|
|
||||||
|
|
||||||
Status: Completed.
|
|
||||||
|
|
||||||
The roadmap has no remaining open product or architecture questions. Later
|
|
||||||
changes to this completed scope require new roadmap or decision-record scope
|
|
||||||
rather than implicit changes to this historical record.
|
|
||||||
@@ -20,8 +20,9 @@ source:
|
|||||||
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
|
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
|
||||||
|
|
||||||
The matching schemas and Promptkit definitions are embedded by
|
The matching schemas and Promptkit definitions are embedded by
|
||||||
`internal/promptassets`. The generated-text catalog pairs each schema ID with
|
`internal/promptassets`. The generated-text catalog requires each report's
|
||||||
its template ID; keep the matching prompt definition aligned with that pair.
|
exact schema/template pair; keep the matching prompt definition aligned with
|
||||||
|
that report-specific triple.
|
||||||
|
|
||||||
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
||||||
|
|
||||||
@@ -46,11 +47,18 @@ from rendering.
|
|||||||
calculations, source selection, or prompt-input shaping to a template.
|
calculations, source selection, or prompt-input shaping to a template.
|
||||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||||
in generated prose merely to compensate for a template change.
|
in generated prose merely to compensate for a template change.
|
||||||
|
- Render every `.GeneratedText` value through `plainText`. It preserves prose
|
||||||
|
and paragraph breaks while escaping Markdown and HTML syntax, removing code
|
||||||
|
indentation, and replacing control characters. Never interpolate generated
|
||||||
|
prose directly: repository templates alone own headings, lists, links, and
|
||||||
|
other Markdown structure.
|
||||||
- When changing the generated-prose contract, update the matching prompt,
|
- When changing the generated-prose contract, update the matching prompt,
|
||||||
schema, validator, render context, and template together. The validation and
|
schema, validator, render context, and template together. The validation and
|
||||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||||
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
||||||
`.Modules.DerivedDaypartSummaries`, which is a map.
|
`.Modules.DerivedDaypartSummaries`, which is a map. The Today partial uses
|
||||||
|
`.Modules.HasDaypartDetails` to ensure its heading has either rows or the
|
||||||
|
explicit no-details fallback.
|
||||||
|
|
||||||
Minimal optional-value pattern:
|
Minimal optional-value pattern:
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ Minimal list pattern:
|
|||||||
|
|
||||||
```gotemplate
|
```gotemplate
|
||||||
{{ range .GeneratedText.ForecastDiscussion }}
|
{{ range .GeneratedText.ForecastDiscussion }}
|
||||||
{{ . }}
|
{{ plainText . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,6 +87,7 @@ Templates have these helpers in addition to Go template built-ins:
|
|||||||
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
||||||
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
||||||
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
||||||
|
| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
|
||||||
|
|
||||||
For example, the alert partial uses the first two functions to decide whether
|
For example, the alert partial uses the first two functions to decide whether
|
||||||
to render the section:
|
to render the section:
|
||||||
@@ -91,7 +100,7 @@ to render the section:
|
|||||||
|
|
||||||
## Render Context
|
## Render Context
|
||||||
|
|
||||||
Every rendered template receives one typed context with these five top-level
|
Every rendered template receives one typed context with these three top-level
|
||||||
fields:
|
fields:
|
||||||
|
|
||||||
| Field | Purpose |
|
| Field | Purpose |
|
||||||
@@ -99,12 +108,6 @@ fields:
|
|||||||
| `.Report` | Display labels and canonical report timing metadata. |
|
| `.Report` | Display labels and canonical report timing metadata. |
|
||||||
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
| `.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. |
|
|
||||||
| `.Derived` | Shared calculated facts for advanced use. |
|
|
||||||
|
|
||||||
`.Collected` and `.Derived` are available for an exceptional display need, but
|
|
||||||
they are lower-level contracts. Keep reusable weather derivation in Go and use
|
|
||||||
the module surface for normal template work.
|
|
||||||
|
|
||||||
### Report Metadata
|
### Report Metadata
|
||||||
|
|
||||||
@@ -126,14 +129,15 @@ It is not a source for deterministic weather facts.
|
|||||||
|
|
||||||
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
| `.GeneratedText.Summary` | `string` | `string` | Required; at most 4,000 characters. |
|
||||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; Hourly permits 12,000 characters. Day-style values permit up to 12 paragraphs of 4,000 characters each. |
|
||||||
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Optional prose used by the precipitation partial when deterministic windows exist. |
|
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field, at most 4,000 characters; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
|
||||||
| `.GeneratedText.Confidence` | `string` | `string` | Optional validated prose; the current templates do not render it. |
|
|
||||||
|
|
||||||
The JSON schema rejects unknown properties and defines the required fields, but
|
The JSON schema rejects unknown properties and defines the required fields, but
|
||||||
the schema body and validation behavior are documented in [Generated Text
|
the schema body and validation behavior are documented in [Generated Text
|
||||||
internals](internal/generatedtext.md).
|
internals](internal/generatedtext.md). All validated generated prose together
|
||||||
|
is limited to 20,000 characters, so template edits can rely on a bounded prose
|
||||||
|
surface.
|
||||||
|
|
||||||
### Deterministic Module Values
|
### Deterministic Module Values
|
||||||
|
|
||||||
@@ -142,8 +146,9 @@ Module pointers can be nil when their source or policy permits omission.
|
|||||||
|
|
||||||
| Module field | Available in |
|
| Module field | Available in |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
|
| `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
|
||||||
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
||||||
|
| `.Modules.HasDaypartDetails` | Today |
|
||||||
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||||
| `.Modules.TodayPlanning` | Today |
|
| `.Modules.TodayPlanning` | Today |
|
||||||
| `.Modules.TomorrowPlanning` | Tomorrow |
|
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# Troubleshooting
|
|
||||||
|
|
||||||
Keep failed workspace artifacts in place. When a RunID is available, start
|
|
||||||
with `weatherreporter inspect metadata RUN_ID` and use the paths in its result.
|
|
||||||
|
|
||||||
## Prompt inspection or credentials fail before collection
|
|
||||||
|
|
||||||
A prompt/version, contract, selected profile, unsupported direct-key profile,
|
|
||||||
or required environment credential can fail before weather collection. Correct
|
|
||||||
the configured `promptkit` profile or profile source, confirm the exact
|
|
||||||
Promptkit asset is available, and supply any reported environment credential.
|
|
||||||
Do not add provider keys to YAML. See [configuration](config.md).
|
|
||||||
|
|
||||||
## Preparation, capacity, or execution fails
|
|
||||||
|
|
||||||
A preparation failure occurs before provider work; an execution failure occurs
|
|
||||||
after preparation. Both leave safe provenance and metadata when reached. A
|
|
||||||
capacity error for one batch report does not retry that report or prevent later
|
|
||||||
independent reports. Inspect the preparation or execution path, correct the
|
|
||||||
profile/backend condition, and create a new run. See [operations](operations.md).
|
|
||||||
|
|
||||||
## Generated text fails validation
|
|
||||||
|
|
||||||
Raw generated output may be saved but Markdown is not rendered when the JSON
|
|
||||||
does not match the report schema. Correct the Promptkit prompt/profile behavior
|
|
||||||
or the matching schema and validator in source control; do not edit raw output
|
|
||||||
to treat it as validated. See [templates](templates.md).
|
|
||||||
|
|
||||||
## Debug capture fails
|
|
||||||
|
|
||||||
`--llm-debug-dir` must be an absolute secure directory outside workspace state.
|
|
||||||
A debug-write failure stops the affected report to avoid continuing without the
|
|
||||||
requested diagnostic. Repair the named path's ownership or permissions, then
|
|
||||||
rerun. Treat capture files as sensitive. See [operations](operations.md).
|
|
||||||
|
|
||||||
## Weather, state, output, or notification fails
|
|
||||||
|
|
||||||
Collection errors precede planning. Later filesystem, output-copy, template,
|
|
||||||
or Distributor errors retain the reached safe paths in the summary. Repair only
|
|
||||||
the reported endpoint or path, leave successful managed reports intact, and
|
|
||||||
rerun the affected report or batch. A batch notification is intentionally
|
|
||||||
skipped when any report item fails.
|
|
||||||
|
|
||||||
## Secrets cannot be loaded
|
|
||||||
|
|
||||||
Secret files must be regular non-symlink files directly beneath
|
|
||||||
`secrets.directory` with valid environment-variable basenames. Correct the
|
|
||||||
reported file or directory without placing secret values in YAML.
|
|
||||||
@@ -14,6 +14,9 @@ location:
|
|||||||
secrets:
|
secrets:
|
||||||
directory: ""
|
directory: ""
|
||||||
|
|
||||||
|
output:
|
||||||
|
directory: /var/lib/weatherreporter/reports
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
distributor:
|
distributor:
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -40,14 +43,6 @@ promptkit:
|
|||||||
local:
|
local:
|
||||||
concurrency_limit: 1
|
concurrency_limit: 1
|
||||||
|
|
||||||
workspace:
|
|
||||||
root: workspace
|
|
||||||
snapshots_dir: snapshots
|
|
||||||
reports_dir: reports
|
|
||||||
data_packages_dir: data-packages
|
|
||||||
preflight_dir: preflight
|
|
||||||
notifications_dir: notifications
|
|
||||||
|
|
||||||
dayparts:
|
dayparts:
|
||||||
- name: overnight
|
- name: overnight
|
||||||
start: "00:00"
|
start: "00:00"
|
||||||
@@ -65,12 +60,6 @@ dayparts:
|
|||||||
start: "17:00"
|
start: "17:00"
|
||||||
end: "24:00"
|
end: "24:00"
|
||||||
|
|
||||||
recent_change:
|
|
||||||
temperature_degrees: 5
|
|
||||||
precip_probability_points: 20
|
|
||||||
wind_gust_miles_per_hour: 10
|
|
||||||
precip_timing_shift_minutes: 120
|
|
||||||
|
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
distributor:
|
distributor:
|
||||||
|
|||||||
4
examples/weather-light-local-profile.yml
Normal file
4
examples/weather-light-local-profile.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: weather-light
|
||||||
|
endpoint: http://127.0.0.1:11434/v1
|
||||||
|
model: weather-local
|
||||||
|
timeout_seconds: 180
|
||||||
9
go.mod
9
go.mod
@@ -6,10 +6,9 @@ require gopkg.in/yaml.v3 v3.0.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.4.0
|
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
golang.org/x/sys v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/text v0.14.0 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
|
||||||
golang.org/x/text v0.14.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +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.5.0 h1:jnpazLyyNhWrB2xzwwtUkNUfktkTdkENTwuSPnKiYrc=
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.4.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
gitea.maximumdirect.net/eric/promptkit v0.5.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
package distributor
|
package distributor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +24,7 @@ type Client struct {
|
|||||||
TokenEnv string
|
TokenEnv string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
newUploadClient uploadClientFactory
|
newUploadClient uploadClientFactory
|
||||||
|
pollWait func(context.Context, time.Duration) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadRequest struct {
|
type UploadRequest struct {
|
||||||
@@ -107,6 +110,10 @@ type runStatus struct {
|
|||||||
|
|
||||||
const statusPollInterval = 250 * time.Millisecond
|
const statusPollInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
const maxDistributorResponseBytes int64 = 1 << 20
|
||||||
|
|
||||||
|
var errDistributorResponseTooLarge = fmt.Errorf("distributor response exceeds the %d-byte limit", maxDistributorResponseBytes)
|
||||||
|
|
||||||
func New(cfg config.DistributorNotifyConfig) *Client {
|
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||||
return newClient(cfg, newDistributorUploadClient)
|
return newClient(cfg, newDistributorUploadClient)
|
||||||
}
|
}
|
||||||
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
|
|||||||
TokenEnv: cfg.TokenEnv,
|
TokenEnv: cfg.TokenEnv,
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
newUploadClient: factory,
|
newUploadClient: factory,
|
||||||
|
pollWait: waitForPoll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
Status: result.Status,
|
Status: result.Status,
|
||||||
UploadStatus: result.Status,
|
UploadStatus: result.Status,
|
||||||
}
|
}
|
||||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
pollWait := c.pollWait
|
||||||
|
if pollWait == nil {
|
||||||
|
pollWait = waitForPoll
|
||||||
|
}
|
||||||
|
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0, pollWait)
|
||||||
|
status = sanitizeRunStatus(status)
|
||||||
if status.RunID != "" || status.Status != "" {
|
if status.RunID != "" || status.Status != "" {
|
||||||
uploadResult.RunStatus = &RunStatus{
|
uploadResult.RunStatus = &RunStatus{
|
||||||
RunID: status.RunID,
|
RunID: status.RunID,
|
||||||
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if statusErr != nil {
|
if statusErr != nil {
|
||||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
|
||||||
return uploadResult, nil
|
return uploadResult, nil
|
||||||
}
|
}
|
||||||
if status.Status == "failed" {
|
if status.Status == "failed" {
|
||||||
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
return uploadResult, nil
|
return uploadResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool, wait func(context.Context, time.Duration) error) (runStatus, error) {
|
||||||
status, err := client.Status(ctx, runID)
|
status, err := client.Status(ctx, runID)
|
||||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
timer := time.NewTimer(statusPollInterval)
|
if err := wait(ctx, statusPollInterval); err != nil {
|
||||||
select {
|
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next, err := client.Status(ctx, runID)
|
next, err := client.Status(ctx, runID)
|
||||||
@@ -253,6 +262,17 @@ func waitForRunStatus(ctx context.Context, client uploadClient, runID string, po
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForPoll(ctx context.Context, interval time.Duration) error {
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func terminalRunStatus(status string) bool {
|
func terminalRunStatus(status string) bool {
|
||||||
return status == "succeeded" || status == "failed"
|
return status == "succeeded" || status == "failed"
|
||||||
}
|
}
|
||||||
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
|
|||||||
client *distributorupload.Client
|
client *distributorupload.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type boundedResponseTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t boundedResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
base := t.base
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
response, err := base.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(response.Body, t.limit+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > t.limit {
|
||||||
|
return nil, errDistributorResponseTooLarge
|
||||||
|
}
|
||||||
|
response.Body = io.NopCloser(bytes.NewReader(data))
|
||||||
|
response.ContentLength = int64(len(data))
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteResponseError struct {
|
||||||
|
StatusCode int
|
||||||
|
Retryable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteResponseError) Error() string {
|
||||||
|
if e == nil || e.StatusCode == 0 {
|
||||||
|
return "distributor request failed"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("distributor request failed with HTTP status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||||
httpClient := (*http.Client)(nil)
|
httpClient := &http.Client{
|
||||||
|
Transport: boundedResponseTransport{base: http.DefaultTransport, limit: maxDistributorResponseBytes},
|
||||||
|
}
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
httpClient = &http.Client{Timeout: timeout}
|
httpClient.Timeout = timeout
|
||||||
}
|
}
|
||||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return runStatus{}, err
|
return runStatus{}, err
|
||||||
}
|
}
|
||||||
return runStatus{
|
return sanitizeRunStatus(runStatus{
|
||||||
RunID: status.RunID,
|
RunID: status.RunID,
|
||||||
PipelineID: status.PipelineID,
|
PipelineID: status.PipelineID,
|
||||||
Status: status.Status,
|
Status: status.Status,
|
||||||
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
|||||||
FinishedAt: status.FinishedAt,
|
FinishedAt: status.FinishedAt,
|
||||||
Report: append(json.RawMessage(nil), status.Report...),
|
Report: append(json.RawMessage(nil), status.Report...),
|
||||||
Error: status.Error,
|
Error: status.Error,
|
||||||
}, nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type uploadErrorContext struct {
|
type uploadErrorContext struct {
|
||||||
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
|
|||||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||||
var conflict *distributorupload.IdempotencyConflictError
|
var conflict *distributorupload.IdempotencyConflictError
|
||||||
isConflict := errors.As(err, &conflict)
|
isConflict := errors.As(err, &conflict)
|
||||||
err = redactToken(err, ctx.Token)
|
err = safeDistributorDiagnostic(err, ctx.Token)
|
||||||
if isConflict {
|
if isConflict {
|
||||||
return &IdempotencyConflictError{
|
return &IdempotencyConflictError{
|
||||||
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
||||||
@@ -340,6 +402,31 @@ func wrapUploadError(err error, ctx uploadErrorContext) error {
|
|||||||
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeDistributorDiagnostic(err error, token string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, errDistributorResponseTooLarge) {
|
||||||
|
return errDistributorResponseTooLarge
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return redactToken(err, token)
|
||||||
|
}
|
||||||
|
var httpErr *distributorupload.HTTPError
|
||||||
|
if errors.As(err, &httpErr) {
|
||||||
|
return &RemoteResponseError{StatusCode: httpErr.StatusCode, Retryable: httpErr.Retryable}
|
||||||
|
}
|
||||||
|
return errors.New("distributor request failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRunStatus(status runStatus) runStatus {
|
||||||
|
status.Report = nil
|
||||||
|
if status.Error != "" {
|
||||||
|
status.Error = "distributor reported a failed run"
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
func uploadSourcePaths(files []UploadFile) []string {
|
func uploadSourcePaths(files []UploadFile) []string {
|
||||||
paths := make([]string, 0, len(files))
|
paths := make([]string, 0, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
310
internal/adapters/distributor/client_http_test.go
Normal file
310
internal/adapters/distributor/client_http_test.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package distributor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const oversizedRemoteDiagnostic = "REMOTE-DIAGNOSTIC"
|
||||||
|
|
||||||
|
func TestUploadUsesProductionHTTPBoundary(t *testing.T) {
|
||||||
|
const token = "test-upload-token"
|
||||||
|
var uploadCalls, statusCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/prefix/v1/pipelines/weather/upload":
|
||||||
|
uploadCalls++
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Idempotency-Key"); got != "bundle-key" {
|
||||||
|
t.Fatalf("idempotency key = %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Content-Type"); got != "application/gzip" {
|
||||||
|
t.Fatalf("content type = %q", got)
|
||||||
|
}
|
||||||
|
verifyUploadedArchive(t, r.Body, "daily/report.md", "report body")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/prefix/runs/run-123":
|
||||||
|
statusCalls++
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":{"detail":"REMOTE-DETAIL"}}`)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := productionClient(t, server.URL+"/prefix", token)
|
||||||
|
result, err := client.Upload(context.Background(), productionUploadRequest(t))
|
||||||
|
if err != nil || uploadCalls != 1 || statusCalls != 1 || result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" || result.RunStatus == nil || result.RunStatus.PipelineID != "weather" || len(result.RunStatus.Report) != 0 {
|
||||||
|
t.Fatalf("result/error/calls = %#v/%v/%d/%d", result, err, uploadCalls, statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadClassifiesRemoteHTTPDiagnostics(t *testing.T) {
|
||||||
|
const token = "test-upload-token"
|
||||||
|
const remote = oversizedRemoteDiagnostic
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
handle func(http.ResponseWriter, *http.Request)
|
||||||
|
check func(t *testing.T, result UploadResult, err error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "upload failure",
|
||||||
|
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s", r.Method)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"REMOTE-DIAGNOSTIC","retryable":true}`)
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, _ UploadResult, err error) {
|
||||||
|
t.Helper()
|
||||||
|
var remoteErr *RemoteResponseError
|
||||||
|
if err == nil || !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest || !remoteErr.Retryable {
|
||||||
|
t.Fatalf("error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status failure",
|
||||||
|
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = io.WriteString(w, remote)
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, result UploadResult, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil || result.Status != "accepted" || result.StatusError != "distributor request failed with HTTP status 500" {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed run",
|
||||||
|
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"failed","error":"REMOTE-DIAGNOSTIC","report":{"detail":"REMOTE-DIAGNOSTIC"}}`)
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, result UploadResult, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil || result.Status != "failed" || result.RunStatus == nil || result.RunStatus.Error != "distributor reported a failed run" || len(result.RunStatus.Report) != 0 {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(tt.handle))
|
||||||
|
defer server.Close()
|
||||||
|
result, err := productionClient(t, server.URL, token).Upload(context.Background(), productionUploadRequest(t))
|
||||||
|
tt.check(t, result, err)
|
||||||
|
for _, value := range []string{fmt.Sprint(result), fmt.Sprint(err)} {
|
||||||
|
if strings.Contains(value, remote) || strings.Contains(value, token) {
|
||||||
|
t.Fatalf("normal diagnostic leaked remote value: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadBoundsHTTPResponses(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
response func(size int) string
|
||||||
|
statusCode int
|
||||||
|
statusBody func(size int) string
|
||||||
|
check func(t *testing.T, result UploadResult, err error, overflow bool)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "accepted response",
|
||||||
|
response: func(size int) string {
|
||||||
|
return paddedJSON(t, `{"run_id":"run-123","status":"accepted","detail":"REMOTE-DIAGNOSTIC"}`, size)
|
||||||
|
},
|
||||||
|
statusBody: func(_ int) string {
|
||||||
|
return `{"run_id":"run-123","status":"succeeded"}`
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||||
|
t.Helper()
|
||||||
|
if overflow {
|
||||||
|
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
|
||||||
|
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.Status != "succeeded" {
|
||||||
|
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status report",
|
||||||
|
response: func(_ int) string {
|
||||||
|
return `{"run_id":"run-123","status":"accepted"}`
|
||||||
|
},
|
||||||
|
statusBody: func(size int) string { return statusReportBody(t, size) },
|
||||||
|
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||||
|
t.Helper()
|
||||||
|
if overflow {
|
||||||
|
if err != nil || result.Status != "accepted" || result.StatusError != errDistributorResponseTooLarge.Error() {
|
||||||
|
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||||
|
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error response",
|
||||||
|
response: func(size int) string { return repeatedToLength(oversizedRemoteDiagnostic, size) },
|
||||||
|
statusCode: http.StatusBadRequest,
|
||||||
|
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||||
|
t.Helper()
|
||||||
|
if overflow {
|
||||||
|
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
|
||||||
|
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var remoteErr *RemoteResponseError
|
||||||
|
if !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, overflow := range []bool{false, true} {
|
||||||
|
t.Run(tt.name+"/"+map[bool]string{false: "limit", true: "over-limit"}[overflow], func(t *testing.T) {
|
||||||
|
size := int(maxDistributorResponseBytes)
|
||||||
|
if overflow {
|
||||||
|
size++
|
||||||
|
}
|
||||||
|
var uploadCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
uploadCalls++
|
||||||
|
statusCode := tt.statusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusAccepted
|
||||||
|
}
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
_, _ = io.WriteString(w, tt.response(size))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, tt.statusBody(size))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := productionClient(t, server.URL, "test-upload-token").Upload(context.Background(), productionUploadRequest(t))
|
||||||
|
tt.check(t, result, err, overflow)
|
||||||
|
if strings.Contains(fmt.Sprint(result), oversizedRemoteDiagnostic) || strings.Contains(fmt.Sprint(err), oversizedRemoteDiagnostic) {
|
||||||
|
t.Fatalf("result/error leaked oversized response detail: %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
if uploadCalls != 1 {
|
||||||
|
t.Fatalf("upload calls = %d, want one", uploadCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productionClient(t *testing.T, endpoint, token string) *Client {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = endpoint
|
||||||
|
cfg.Timeout = 0
|
||||||
|
t.Setenv(cfg.TokenEnv, token)
|
||||||
|
return New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func productionUploadRequest(t *testing.T) UploadRequest {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "report.md")
|
||||||
|
if err := os.WriteFile(path, []byte("report body"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return UploadRequest{
|
||||||
|
PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "bundle-key",
|
||||||
|
Files: []UploadFile{{SourcePath: path, BundlePath: "daily/report.md"}},
|
||||||
|
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyUploadedArchive(t *testing.T, body io.Reader, wantPath, wantContents string) {
|
||||||
|
t.Helper()
|
||||||
|
reader, err := gzip.NewReader(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
archive := tar.NewReader(reader)
|
||||||
|
for {
|
||||||
|
header, err := archive.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if header.Name != wantPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contents, err := io.ReadAll(archive)
|
||||||
|
if err != nil || string(contents) != wantContents {
|
||||||
|
t.Fatalf("archive file contents/error = %q/%v", contents, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("archive did not contain %q", wantPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func paddedJSON(t *testing.T, value string, size int) string {
|
||||||
|
t.Helper()
|
||||||
|
if len(value) > size {
|
||||||
|
t.Fatalf("JSON length = %d, exceeds requested size %d", len(value), size)
|
||||||
|
}
|
||||||
|
return value + strings.Repeat(" ", size-len(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusReportBody(t *testing.T, size int) string {
|
||||||
|
t.Helper()
|
||||||
|
const prefix = `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":"`
|
||||||
|
const suffix = `"}`
|
||||||
|
if len(prefix)+len(suffix) > size {
|
||||||
|
t.Fatalf("status response exceeds requested size %d", size)
|
||||||
|
}
|
||||||
|
return prefix + repeatedToLength(oversizedRemoteDiagnostic, size-len(prefix)-len(suffix)) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatedToLength(value string, size int) string {
|
||||||
|
return strings.Repeat(value, size/len(value)+1)[:size]
|
||||||
|
}
|
||||||
@@ -45,8 +45,8 @@ func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
|||||||
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||||
t.Fatalf("result = %#v, want accepted run", result)
|
t.Fatalf("result = %#v, want accepted run", result)
|
||||||
}
|
}
|
||||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
|
||||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
|
||||||
}
|
}
|
||||||
if factory.endpoint != cfg.Endpoint {
|
if factory.endpoint != cfg.Endpoint {
|
||||||
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||||
@@ -242,13 +242,14 @@ func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
client := newClient(cfg, factory.newClient)
|
client := newClient(cfg, factory.newClient)
|
||||||
|
client.pollWait = func(context.Context, time.Duration) error { return nil }
|
||||||
|
|
||||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upload() error = %v", err)
|
t.Fatalf("Upload() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
if result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
|
||||||
}
|
}
|
||||||
if factory.client.statusCalls != 2 {
|
if factory.client.statusCalls != 2 {
|
||||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||||
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Upload() error = nil, want failed distributor run error")
|
t.Fatal("Upload() error = nil, want failed distributor run error")
|
||||||
}
|
}
|
||||||
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
|
if result.RunStatus == nil || result.RunStatus.Status != "failed" || len(result.RunStatus.Report) != 0 || result.RunStatus.Error != "distributor reported a failed run" {
|
||||||
t.Fatalf("result = %#v, want failed run status report", result)
|
t.Fatalf("result = %#v, want safe failed run status", result)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
||||||
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||||
|
// It supports concurrent Execute calls on the shared executor.
|
||||||
type Adapter struct {
|
type Adapter struct {
|
||||||
engine *promptkit.Engine
|
engine *promptkit.Engine
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter,
|
|||||||
options := []promptkit.Option{
|
options := []promptkit.Option{
|
||||||
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||||
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||||
|
promptkit.WithFallbackProfileFS(promptassets.ProfileFS(), "."),
|
||||||
}
|
}
|
||||||
if config.ProfileFile != "" {
|
if config.ProfileFile != "" {
|
||||||
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
||||||
@@ -123,9 +126,7 @@ func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteR
|
|||||||
PromptID: request.PromptID,
|
PromptID: request.PromptID,
|
||||||
PromptVersion: request.PromptVersion,
|
PromptVersion: request.PromptVersion,
|
||||||
ProfileID: request.ProfileID,
|
ProfileID: request.ProfileID,
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{"data_package": promptkit.Inline(string(append([]byte(nil), request.DataPackage...)))},
|
||||||
"data_package": promptkit.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, classifyError(err)
|
return nil, classifyError(err)
|
||||||
@@ -133,7 +134,7 @@ func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteR
|
|||||||
defer prepared.Discard()
|
defer prepared.Discard()
|
||||||
|
|
||||||
details := prepared.Details()
|
details := prepared.Details()
|
||||||
preparation, debug := preparationValues(details, request.DataPackagePath, request.CaptureDebug)
|
preparation, debug := preparationValues(details, request.CaptureDebug)
|
||||||
if preparedCallback != nil {
|
if preparedCallback != nil {
|
||||||
if err := preparedCallback(preparation, debug); err != nil {
|
if err := preparedCallback(preparation, debug); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -144,7 +145,7 @@ func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, classifyError(err)
|
return nil, classifyError(err)
|
||||||
}
|
}
|
||||||
return executionValue(result, request.DataPackagePath, request.CaptureDebug), nil
|
return executionValue(result, request.CaptureDebug), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
||||||
@@ -155,7 +156,7 @@ func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preparationValues(value promptkit.PreparedRun, dataPackagePath string, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
func preparationValues(value promptkit.PreparedRun, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
||||||
preparation := promptexec.Preparation{
|
preparation := promptexec.Preparation{
|
||||||
PromptID: value.PromptID,
|
PromptID: value.PromptID,
|
||||||
PromptVersion: value.PromptVersion,
|
PromptVersion: value.PromptVersion,
|
||||||
@@ -169,7 +170,6 @@ func preparationValues(value promptkit.PreparedRun, dataPackagePath string, capt
|
|||||||
StartedAt: value.StartTime,
|
StartedAt: value.StartTime,
|
||||||
EndedAt: value.EndTime,
|
EndedAt: value.EndTime,
|
||||||
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
||||||
DataPackagePath: dataPackagePath,
|
|
||||||
}
|
}
|
||||||
if !captureDebug {
|
if !captureDebug {
|
||||||
return preparation, nil
|
return preparation, nil
|
||||||
@@ -185,7 +185,7 @@ func preparationValues(value promptkit.PreparedRun, dataPackagePath string, capt
|
|||||||
return preparation, debug
|
return preparation, debug
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionValue(value *promptkit.RunResult, dataPackagePath string, captureDebug bool) *promptexec.Execution {
|
func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.Execution {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -195,6 +195,17 @@ func executionValue(value *promptkit.RunResult, dataPackagePath string, captureD
|
|||||||
value.Validation.SchemaPath,
|
value.Validation.SchemaPath,
|
||||||
value.Validation.Errors,
|
value.Validation.Errors,
|
||||||
)
|
)
|
||||||
|
rawOutput := []byte(nil)
|
||||||
|
if len(value.RawOutput) <= generatedtext.MaxGeneratedTextBytes {
|
||||||
|
rawOutput = []byte(value.RawOutput)
|
||||||
|
} else {
|
||||||
|
validation = promptexec.NewValidation(
|
||||||
|
promptexec.ValidationFailed,
|
||||||
|
string(value.Validation.Mode),
|
||||||
|
value.Validation.SchemaPath,
|
||||||
|
[]string{"generated output exceeds the configured size limit"},
|
||||||
|
)
|
||||||
|
}
|
||||||
execution := &promptexec.Execution{
|
execution := &promptexec.Execution{
|
||||||
RunID: value.RunID,
|
RunID: value.RunID,
|
||||||
PromptID: value.PromptID,
|
PromptID: value.PromptID,
|
||||||
@@ -213,16 +224,15 @@ func executionValue(value *promptkit.RunResult, dataPackagePath string, captureD
|
|||||||
CachedTokens: value.Usage.CachedTokens,
|
CachedTokens: value.Usage.CachedTokens,
|
||||||
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
||||||
},
|
},
|
||||||
StartedAt: value.StartTime,
|
StartedAt: value.StartTime,
|
||||||
EndedAt: value.EndTime,
|
EndedAt: value.EndTime,
|
||||||
Duration: value.Duration,
|
Duration: value.Duration,
|
||||||
Validation: validation,
|
Validation: validation,
|
||||||
DataPackagePath: dataPackagePath,
|
RawOutput: rawOutput,
|
||||||
RawOutput: []byte(value.RawOutput),
|
|
||||||
}
|
}
|
||||||
if captureDebug {
|
if captureDebug {
|
||||||
execution.Debug = &promptexec.ExecutionDebug{
|
execution.Debug = &promptexec.ExecutionDebug{
|
||||||
RawOutput: append([]byte(nil), value.RawOutput...),
|
RawOutput: append([]byte(nil), rawOutput...),
|
||||||
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,13 +260,12 @@ func copyInputHashes(values map[string]string) map[string]string {
|
|||||||
|
|
||||||
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||||
parameters := struct {
|
parameters := struct {
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
TopP float64 `json:"top_p"`
|
TopP float64 `json:"top_p"`
|
||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
ServiceTier string `json:"service_tier"`
|
ServiceTier string `json:"service_tier"`
|
||||||
ReasoningEffort string `json:"reasoning_effort"`
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
ExtraParams map[string]any `json:"extra_params"`
|
|
||||||
}{
|
}{
|
||||||
Temperature: value.Temperature,
|
Temperature: value.Temperature,
|
||||||
MaxTokens: value.MaxTokens,
|
MaxTokens: value.MaxTokens,
|
||||||
@@ -264,7 +273,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|||||||
TimeoutSeconds: value.TimeoutSeconds,
|
TimeoutSeconds: value.TimeoutSeconds,
|
||||||
ServiceTier: value.ServiceTier,
|
ServiceTier: value.ServiceTier,
|
||||||
ReasoningEffort: value.ReasoningEffort,
|
ReasoningEffort: value.ReasoningEffort,
|
||||||
ExtraParams: value.ExtraParams,
|
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(parameters)
|
data, _ := json.Marshal(parameters)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ type fakeClient struct {
|
|||||||
calls int
|
calls int
|
||||||
requests []promptkit.GenerateRequest
|
requests []promptkit.GenerateRequest
|
||||||
block bool
|
block bool
|
||||||
|
started chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingReader struct {
|
type recordingReader struct {
|
||||||
@@ -44,9 +46,13 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
|||||||
client.calls++
|
client.calls++
|
||||||
client.requests = append(client.requests, request)
|
client.requests = append(client.requests, request)
|
||||||
block := client.block
|
block := client.block
|
||||||
|
started := client.started
|
||||||
response := client.response
|
response := client.response
|
||||||
err := client.err
|
err := client.err
|
||||||
client.mu.Unlock()
|
client.mu.Unlock()
|
||||||
|
if started != nil {
|
||||||
|
started <- struct{}{}
|
||||||
|
}
|
||||||
if block {
|
if block {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
@@ -54,6 +60,36 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
|||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
executionErrors := make(chan error, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() {
|
||||||
|
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||||
|
executionErrors <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case <-client.started:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for concurrent Promptkit calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
for range 2 {
|
||||||
|
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
|
||||||
|
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client.callCount() != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", client.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (client *fakeClient) callCount() int {
|
func (client *fakeClient) callCount() int {
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
defer client.mu.Unlock()
|
defer client.mu.Unlock()
|
||||||
@@ -68,11 +104,11 @@ func (client *fakeClient) request() promptkit.GenerateRequest {
|
|||||||
|
|
||||||
func TestInspectPromptAndProfile(t *testing.T) {
|
func TestInspectPromptAndProfile(t *testing.T) {
|
||||||
adapter := newTestAdapter(t, &fakeClient{})
|
adapter := newTestAdapter(t, &fakeClient{})
|
||||||
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0")
|
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.0.0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InspectPrompt() error = %v", err)
|
t.Fatalf("InspectPrompt() error = %v", err)
|
||||||
}
|
}
|
||||||
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
|
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.0.0" || inspection.DefaultProfileID != "weather-balanced" {
|
||||||
t.Fatalf("inspection = %#v", inspection)
|
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" {
|
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
|
||||||
@@ -102,6 +138,107 @@ func TestInspectPromptAndProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedProfilesAreAvailableToProductionAndTestAdapters(t *testing.T) {
|
||||||
|
adapter, err := New(Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []struct {
|
||||||
|
id string
|
||||||
|
backend string
|
||||||
|
model string
|
||||||
|
}{
|
||||||
|
{"weather-light", "openrouter", "deepseek/deepseek-v4-flash"},
|
||||||
|
{"weather-balanced", "openrouter", "~google/gemini-flash-latest"},
|
||||||
|
{"weather-deep", "openrouter", "~anthropic/claude-sonnet-latest"},
|
||||||
|
} {
|
||||||
|
t.Run(want.id, func(t *testing.T) {
|
||||||
|
assertProfile(t, adapter, want.id, want.backend, want.model)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
testAdapter, err := newAdapterForTest(Config{}, &fakeClient{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapterForTest() error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, testAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredProfilesOverrideEmbeddedFallbacks(t *testing.T) {
|
||||||
|
file := writeProfileFile(t, `id: weather-light
|
||||||
|
endpoint: https://local-file.example/v1
|
||||||
|
model: file-light
|
||||||
|
`)
|
||||||
|
fileAdapter, err := New(Config{ProfileFile: file})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(profile file) error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
|
||||||
|
|
||||||
|
directory := testProfileDirectory(t, `id: weather-light
|
||||||
|
backend: local
|
||||||
|
model: directory-light
|
||||||
|
`)
|
||||||
|
directoryAdapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(profile directory) error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, directoryAdapter, "weather-light", promptkit.BackendLocal, "directory-light")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
|
||||||
|
adapter, err := New(Config{ProfileFile: filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, adapter, "weather-light", "", "weather-local")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
|
||||||
|
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: other-profile
|
||||||
|
backend: openrouter
|
||||||
|
model: other-model
|
||||||
|
`)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(absent profile) error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, absentAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||||
|
|
||||||
|
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: weather-light
|
||||||
|
backend: openrouter
|
||||||
|
`)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(malformed profile) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := malformedAdapter.InspectProfile(context.Background(), "weather-light"); err == nil {
|
||||||
|
t.Fatal("InspectProfile() error = nil, want malformed configured profile error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
|
||||||
|
adapter, err := New(Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectProfile(builtin) error = %v", err)
|
||||||
|
}
|
||||||
|
if builtin.ProfileID != "gemini-flash-latest" || builtin.BackendID != "openrouter" || builtin.ModelName == "" {
|
||||||
|
t.Fatalf("builtin profile = %#v", builtin)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit, err := newAdapter(Config{}, promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "weather-light",
|
||||||
|
Endpoint: "https://explicit.example/v1",
|
||||||
|
Model: "explicit-light",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapter(explicit profile) error = %v", err)
|
||||||
|
}
|
||||||
|
assertProfile(t, explicit, "weather-light", "", "explicit-light")
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
||||||
client := &fakeClient{response: validResponse()}
|
client := &fakeClient{response: validResponse()}
|
||||||
adapter := newTestAdapter(t, client)
|
adapter := newTestAdapter(t, client)
|
||||||
@@ -109,7 +246,7 @@ func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
|||||||
callbackCalls := 0
|
callbackCalls := 0
|
||||||
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
callbackCalls++
|
callbackCalls++
|
||||||
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.DataPackagePath != request.DataPackagePath || preparation.ModelName != "test-model" {
|
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.ModelName != "test-model" {
|
||||||
t.Fatalf("preparation = %#v", preparation)
|
t.Fatalf("preparation = %#v", preparation)
|
||||||
}
|
}
|
||||||
if debug != nil {
|
if debug != nil {
|
||||||
@@ -126,7 +263,7 @@ func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
|||||||
if callbackCalls != 1 || client.callCount() != 1 {
|
if callbackCalls != 1 || client.callCount() != 1 {
|
||||||
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
|
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 {
|
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content {
|
||||||
t.Fatalf("result = %#v", result)
|
t.Fatalf("result = %#v", result)
|
||||||
}
|
}
|
||||||
if result.Debug != nil {
|
if result.Debug != nil {
|
||||||
@@ -141,6 +278,43 @@ func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
|
||||||
|
client := &fakeClient{response: hourlyValidResponse()}
|
||||||
|
adapter, err := newAdapter(Config{}, promptkit.WithLLMClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapter() error = %v", err)
|
||||||
|
}
|
||||||
|
request := promptexec.ExecuteRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
PromptVersion: "2.0.0",
|
||||||
|
ProfileID: "weather-light",
|
||||||
|
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
|
||||||
|
}
|
||||||
|
var preparation promptexec.Preparation
|
||||||
|
prepared := false
|
||||||
|
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
|
||||||
|
if client.callCount() != 0 {
|
||||||
|
t.Fatal("provider was called before preparation completed")
|
||||||
|
}
|
||||||
|
preparation = value
|
||||||
|
prepared = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if !prepared || preparation.ProfileID != "weather-light" || preparation.BackendID != "openrouter" || preparation.ModelName != "deepseek/deepseek-v4-flash" {
|
||||||
|
t.Fatalf("preparation = %#v", preparation)
|
||||||
|
}
|
||||||
|
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "openrouter" || result.ModelName != "deepseek/deepseek-v4-flash" || result.Validation.Status != promptexec.ValidationPassed {
|
||||||
|
t.Fatalf("execution = %#v", result)
|
||||||
|
}
|
||||||
|
if client.callCount() != 1 || client.request().Target.Model != "deepseek/deepseek-v4-flash" {
|
||||||
|
t.Fatalf("provider calls/request = %d/%#v", client.callCount(), client.request())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
||||||
client := &fakeClient{response: validResponse()}
|
client := &fakeClient{response: validResponse()}
|
||||||
reader := &recordingReader{}
|
reader := &recordingReader{}
|
||||||
@@ -149,7 +323,7 @@ func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
|||||||
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
|
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
|
||||||
t.Fatalf("Execute() error = %v", err)
|
t.Fatalf("Execute() error = %v", err)
|
||||||
}
|
}
|
||||||
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != request.DataPackagePath || reader.ref.Body != string(request.DataPackage) {
|
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != "" || reader.ref.Body != string(request.DataPackage) {
|
||||||
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +352,30 @@ func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
|
||||||
|
const marker = "private-debug-marker"
|
||||||
|
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: 400,
|
||||||
|
TopP: 0.9,
|
||||||
|
TimeoutSeconds: 30,
|
||||||
|
ServiceTier: "flex",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"access-key": marker,
|
||||||
|
"signature": marker,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
|
||||||
|
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
|
||||||
|
if !strings.Contains(parameters, want) {
|
||||||
|
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||||
client := &fakeClient{response: validResponse()}
|
client := &fakeClient{response: validResponse()}
|
||||||
adapter := newTestAdapter(t, client)
|
adapter := newTestAdapter(t, client)
|
||||||
@@ -202,6 +400,23 @@ func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteDropsOversizedGeneratedOutput(t *testing.T) {
|
||||||
|
client := &fakeClient{response: &promptkit.GenerateResponse{Content: strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1)}}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
request := testExecuteRequest()
|
||||||
|
request.CaptureDebug = true
|
||||||
|
result, err := adapter.Execute(context.Background(), request, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.RawOutput) != 0 || result.Debug == nil || len(result.Debug.RawOutput) != 0 {
|
||||||
|
t.Fatalf("execution = %#v", result)
|
||||||
|
}
|
||||||
|
if len(result.Validation.Diagnostics) != 1 || result.Validation.Diagnostics[0] != "generated output exceeds the configured size limit" {
|
||||||
|
t.Fatalf("diagnostics = %#v", result.Validation.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -293,6 +508,7 @@ func TestNewValidatesConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||||
|
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
|
||||||
profiles := testProfileDirectory(t, `id: local-profile
|
profiles := testProfileDirectory(t, `id: local-profile
|
||||||
backend: local
|
backend: local
|
||||||
model: local-model
|
model: local-model
|
||||||
@@ -339,6 +555,17 @@ func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
|
|||||||
return newTestAdapterWithOptions(t, client)
|
return newTestAdapterWithOptions(t, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertProfile(t *testing.T, adapter *Adapter, id string, backend string, model string) {
|
||||||
|
t.Helper()
|
||||||
|
profile, err := adapter.InspectProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectProfile(%q) error = %v", id, err)
|
||||||
|
}
|
||||||
|
if profile.ProfileID != id || profile.BackendID != backend || profile.ModelName != model {
|
||||||
|
t.Fatalf("profile = %#v, want %q with backend/model %q/%q", profile, id, backend, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
profiles := testProfileDirectory(t, `id: test-profile
|
profiles := testProfileDirectory(t, `id: test-profile
|
||||||
@@ -366,19 +593,34 @@ func testProfileDirectory(t *testing.T, profile string) string {
|
|||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeProfileFile(t *testing.T, profile string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "profile.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func testExecuteRequest() promptexec.ExecuteRequest {
|
func testExecuteRequest() promptexec.ExecuteRequest {
|
||||||
return promptexec.ExecuteRequest{
|
return promptexec.ExecuteRequest{
|
||||||
PromptID: "weather.daily_generated_text",
|
PromptID: "weather.daily_generated_text",
|
||||||
PromptVersion: "1.0.0",
|
PromptVersion: "2.0.0",
|
||||||
ProfileID: "test-profile",
|
ProfileID: "test-profile",
|
||||||
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||||
DataPackagePath: "data-packages/daily/data_package.yaml",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validResponse() *promptkit.GenerateResponse {
|
func validResponse() *promptkit.GenerateResponse {
|
||||||
return &promptkit.GenerateResponse{
|
return &promptkit.GenerateResponse{
|
||||||
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`,
|
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
|
||||||
|
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyValidResponse() *promptkit.GenerateResponse {
|
||||||
|
return &promptkit.GenerateResponse{
|
||||||
|
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
|
||||||
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,24 +15,28 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
currentConditionsEndpoint = "/conditions/current"
|
||||||
|
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
|
||||||
|
|
||||||
defaultWarmupEndpoint = "/conditions/current"
|
defaultWarmupEndpoint = currentConditionsEndpoint
|
||||||
defaultWarmupAttempts = 3
|
defaultWarmupAttempts = 3
|
||||||
defaultWarmupDelay = time.Second
|
defaultWarmupDelay = time.Second
|
||||||
defaultFetchAttempts = 2
|
defaultFetchAttempts = 2
|
||||||
defaultFetchRetryDelay = time.Second
|
defaultFetchRetryDelay = time.Second
|
||||||
|
maxResponseBodyBytes = 10 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -75,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||||
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
|
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
|
||||||
}
|
}
|
||||||
|
if !strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https") {
|
||||||
|
return nil, fmt.Errorf("weather_api.base_url must use http or https")
|
||||||
|
}
|
||||||
|
|
||||||
timeout := cfg.WeatherAPI.Timeout
|
timeout := cfg.WeatherAPI.Timeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
@@ -106,49 +114,32 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||||
if err := c.warmup(ctx); err != nil {
|
warmup, err := c.warmup(ctx)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchedAt := c.now()
|
fetchedAt := c.now()
|
||||||
builder := bundleBuilder{
|
builder := bundleBuilder{
|
||||||
client: c,
|
client: c,
|
||||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||||
fetchedAt: fetchedAt,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := builder.fetchObservation(ctx); err != nil {
|
for _, acquired := range builder.acquireSources(ctx, warmup) {
|
||||||
return nil, err
|
if err := ctx.Err(); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("fetch weather API sources: %w", err)
|
||||||
if err := builder.fetchCurrent(ctx); err != nil {
|
}
|
||||||
return nil, err
|
if err := builder.mergeSource(acquired); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if err := builder.fetchHourly(ctx); err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchNarrative(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchAlerts(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type bundleBuilder struct {
|
type bundleBuilder struct {
|
||||||
client *Client
|
client *Client
|
||||||
bundle *weatherdata.Bundle
|
bundle *weatherdata.Bundle
|
||||||
fetchedAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceRequest struct {
|
type sourceRequest struct {
|
||||||
@@ -165,14 +156,81 @@ type fetchedSource struct {
|
|||||||
source weatherdata.Source
|
source weatherdata.Source
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
type warmupResponse struct {
|
||||||
|
endpoint string
|
||||||
|
requestURL *url.URL
|
||||||
|
body []byte
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceAcquisition struct {
|
||||||
|
request sourceRequest
|
||||||
|
fetched fetchedSource
|
||||||
|
err error
|
||||||
|
warmup warmupResponse
|
||||||
|
usesWarmup bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) acquireSources(ctx context.Context, warmup warmupResponse) []sourceAcquisition {
|
||||||
|
sources := []sourceAcquisition{
|
||||||
|
{request: sourceRequest{name: config.MissingSourceObservations, endpoint: "/observations", query: queryOptions{precision: true}, missingMessage: "observation data is missing"}},
|
||||||
|
{request: currentConditionsRequest()},
|
||||||
|
{request: sourceRequest{name: "hourly", endpoint: "/forecast/hourly", query: queryOptions{precision: true, timezone: true}, missingMessage: "hourly forecast data is missing", required: true, decodeLabel: "hourly forecast"}},
|
||||||
|
{request: sourceRequest{name: config.MissingSourceNarrative, endpoint: "/forecast/narrative", query: queryOptions{precision: true, timezone: true}, missingMessage: "narrative forecast data is missing"}},
|
||||||
|
{request: sourceRequest{name: config.MissingSourceAlerts, endpoint: "/alerts/active", query: queryOptions{allowNull: true}, missingMessage: "active alerts data is missing"}},
|
||||||
|
{request: sourceRequest{name: config.MissingSourceDiscussion, endpoint: "/discussion", query: queryOptions{timezone: true}, missingMessage: "forecast discussion data is missing"}},
|
||||||
|
{request: sourceRequest{name: config.MissingSourceWeatherStory, endpoint: "/weatherstories/latest", query: queryOptions{omitUnits: true}, missingMessage: "NWS weather story data is missing"}},
|
||||||
|
{request: sourceRequest{name: sourceSPCConvectiveOutlooks, endpoint: convectiveOutlooksEndpoint, query: queryOptions{timezone: true, omitUnits: true}, missingMessage: "SPC convective outlook data is missing"}},
|
||||||
|
}
|
||||||
|
if warmup.endpoint == currentConditionsEndpoint {
|
||||||
|
sources[1].warmup = warmup
|
||||||
|
sources[1].usesWarmup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for i := range sources {
|
||||||
|
if sources[i].usesWarmup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer group.Done()
|
||||||
|
request := sources[index].request
|
||||||
|
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||||
|
sources[index].fetched = fetchedSource{raw: raw, source: source}
|
||||||
|
sources[index].err = err
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) mergeSource(acquired sourceAcquisition) error {
|
||||||
|
switch acquired.request.name {
|
||||||
|
case config.MissingSourceObservations:
|
||||||
|
return b.fetchObservation(acquired)
|
||||||
|
case config.MissingSourceCurrent:
|
||||||
|
return b.fetchCurrent(acquired)
|
||||||
|
case "hourly":
|
||||||
|
return b.fetchHourly(acquired)
|
||||||
|
case config.MissingSourceNarrative:
|
||||||
|
return b.fetchNarrative(acquired)
|
||||||
|
case config.MissingSourceAlerts:
|
||||||
|
return b.fetchAlerts(acquired)
|
||||||
|
case config.MissingSourceDiscussion:
|
||||||
|
return b.fetchDiscussion(acquired)
|
||||||
|
case config.MissingSourceWeatherStory:
|
||||||
|
return b.fetchWeatherStory(acquired)
|
||||||
|
case sourceSPCConvectiveOutlooks:
|
||||||
|
return b.fetchSPCConvectiveOutlooks(acquired)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("merge unknown weather source %q", acquired.request.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchObservation(acquired sourceAcquisition) error {
|
||||||
var observation weatherdata.Observation
|
var observation weatherdata.Observation
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
|
||||||
name: "observations",
|
|
||||||
endpoint: "/observations",
|
|
||||||
query: queryOptions{precision: true},
|
|
||||||
missingMessage: "observation data is missing",
|
|
||||||
}, &observation)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -183,14 +241,18 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func currentConditionsRequest() sourceRequest {
|
||||||
var current weatherdata.Current
|
return sourceRequest{
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
name: config.MissingSourceCurrent,
|
||||||
name: "current",
|
endpoint: currentConditionsEndpoint,
|
||||||
endpoint: "/conditions/current",
|
|
||||||
query: queryOptions{precision: true},
|
query: queryOptions{precision: true},
|
||||||
missingMessage: "current conditions data is missing",
|
missingMessage: "current conditions data is missing",
|
||||||
}, ¤t)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchCurrent(acquired sourceAcquisition) error {
|
||||||
|
var current weatherdata.Current
|
||||||
|
fetched, ok, err := b.fetchDecodedSource(acquired, ¤t)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,16 +262,9 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
|
||||||
var hourly weatherdata.ForecastRun
|
var hourly weatherdata.ForecastRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
|
||||||
name: "hourly",
|
|
||||||
endpoint: "/forecast/hourly",
|
|
||||||
query: queryOptions{precision: true, timezone: true},
|
|
||||||
missingMessage: "hourly forecast data is missing",
|
|
||||||
required: true,
|
|
||||||
decodeLabel: "hourly forecast",
|
|
||||||
}, &hourly)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -217,6 +272,14 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
if len(hourly.Periods) == 0 {
|
if len(hourly.Periods) == 0 {
|
||||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||||
}
|
}
|
||||||
|
for i, period := range hourly.Periods {
|
||||||
|
if !period.HasUsableTimeBounds() {
|
||||||
|
return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1)
|
||||||
|
}
|
||||||
|
if !period.HasValidPrecipitationProbability() {
|
||||||
|
return fmt.Errorf("hourly forecast from %s has invalid precipitation probability for period %d", source.Endpoint, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
source.IssuedAt = &hourly.IssuedAt
|
source.IssuedAt = &hourly.IssuedAt
|
||||||
source.UpdatedAt = hourly.UpdatedAt
|
source.UpdatedAt = hourly.UpdatedAt
|
||||||
b.bundle.Hourly = &hourly
|
b.bundle.Hourly = &hourly
|
||||||
@@ -224,14 +287,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
|
||||||
var narrative weatherdata.ForecastRun
|
var narrative weatherdata.ForecastRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
|
||||||
name: "narrative",
|
|
||||||
endpoint: "/forecast/narrative",
|
|
||||||
query: queryOptions{precision: true, timezone: true},
|
|
||||||
missingMessage: "narrative forecast data is missing",
|
|
||||||
}, &narrative)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -243,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
fetched, ok, err := b.fetchSource(acquired)
|
||||||
if err != nil {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
raw, source := fetched.raw, fetched.source
|
||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
|
||||||
}
|
|
||||||
if isJSONNull(raw) {
|
if isJSONNull(raw) {
|
||||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
b.bundle.Alerts = &weatherdata.AlertRun{}
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
return b.handleMalformed(&source, err, acquired.request)
|
||||||
}
|
}
|
||||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
|
||||||
if alerts.AsOf != nil {
|
if alerts.AsOf != nil {
|
||||||
source.IssuedAt = alerts.AsOf
|
source.IssuedAt = alerts.AsOf
|
||||||
}
|
}
|
||||||
@@ -269,14 +324,9 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
|
||||||
var discussion weatherdata.Discussion
|
var discussion weatherdata.Discussion
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
|
||||||
name: "discussion",
|
|
||||||
endpoint: "/discussion",
|
|
||||||
query: queryOptions{timezone: true},
|
|
||||||
missingMessage: "forecast discussion data is missing",
|
|
||||||
}, &discussion)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -288,18 +338,16 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
|
||||||
var story weatherdata.WeatherStory
|
var story weatherdata.WeatherStory
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
|
||||||
name: "weather_story",
|
|
||||||
endpoint: "/weatherstories/latest",
|
|
||||||
query: queryOptions{omitUnits: true},
|
|
||||||
missingMessage: "NWS weather story data is missing",
|
|
||||||
}, &story)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source := fetched.source
|
source := fetched.source
|
||||||
|
if !story.HasUsableContent() {
|
||||||
|
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
|
||||||
|
}
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
@@ -309,14 +357,9 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
|
||||||
var run weatherdata.ConvectiveOutlookRun
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &run)
|
||||||
name: sourceSPCConvectiveOutlooks,
|
|
||||||
endpoint: convectiveOutlooksEndpoint,
|
|
||||||
query: queryOptions{timezone: true, omitUnits: true},
|
|
||||||
missingMessage: "SPC convective outlook data is missing",
|
|
||||||
}, &run)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -332,26 +375,35 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
func (b *bundleBuilder) fetchDecodedSource(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
|
||||||
fetched, ok, err := b.fetchSource(ctx, request)
|
fetched, ok, err := b.fetchSource(acquired)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return fetchedSource{}, false, err
|
return fetchedSource{}, false, err
|
||||||
}
|
}
|
||||||
|
return b.decodeFetchedSource(fetched, acquired.request, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) decodeFetchedSource(fetched fetchedSource, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||||
if err := decodeSource(fetched.raw, target); err != nil {
|
if err := decodeSource(fetched.raw, target); err != nil {
|
||||||
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||||
}
|
}
|
||||||
return fetched, true, nil
|
return fetched, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
func (b *bundleBuilder) fetchSource(acquired sourceAcquisition) (fetchedSource, bool, error) {
|
||||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
if acquired.usesWarmup {
|
||||||
if err != nil {
|
raw, source, err := b.client.decodeSourceResponse(acquired.request.name, acquired.request.endpoint, acquired.request.query, acquired.warmup.requestURL, acquired.warmup.body, acquired.warmup.fetchedAt)
|
||||||
return fetchedSource{}, false, err
|
if err != nil {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
acquired.fetched = fetchedSource{raw: raw, source: source}
|
||||||
|
} else if acquired.err != nil {
|
||||||
|
return fetchedSource{}, false, acquired.err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
if acquired.fetched.raw == nil {
|
||||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
|
||||||
}
|
}
|
||||||
return fetchedSource{raw: raw, source: source}, true, nil
|
return acquired.fetched, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||||
@@ -422,7 +474,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, weatherdata.Source{}, err
|
return nil, weatherdata.Source{}, err
|
||||||
}
|
}
|
||||||
|
return c.decodeSourceResponse(sourceName, endpoint, opts, reqURL, body, c.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) decodeSourceResponse(sourceName string, endpoint string, opts queryOptions, reqURL *url.URL, body []byte, fetchedAt time.Time) (json.RawMessage, weatherdata.Source, error) {
|
||||||
var env envelope
|
var env envelope
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||||
@@ -432,7 +487,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
Name: sourceName,
|
Name: sourceName,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
FetchedAt: c.now(),
|
FetchedAt: fetchedAt,
|
||||||
}
|
}
|
||||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
@@ -446,53 +501,40 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
return env.Data, source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) warmup(ctx context.Context) error {
|
func (c *Client) warmup(ctx context.Context) (warmupResponse, error) {
|
||||||
endpoint := c.warmupEndpoint
|
endpoint := c.warmupEndpoint
|
||||||
if strings.TrimSpace(endpoint) == "" {
|
if strings.TrimSpace(endpoint) == "" {
|
||||||
endpoint = defaultWarmupEndpoint
|
endpoint = defaultWarmupEndpoint
|
||||||
}
|
}
|
||||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastRetryable bool
|
||||||
for attempt := 1; attempt <= attempts; attempt++ {
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
return warmupResponse{}, fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
if err := c.warmupOnce(ctx, endpoint); err != nil {
|
reqURL, body, err := c.warmupOnce(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
lastRetryable = isRetryableRequestError(err)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
|
||||||
}
|
}
|
||||||
if attempt == attempts {
|
if !lastRetryable || attempt == attempts {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||||
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
return warmupResponse{}, fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
if !lastRetryable {
|
||||||
|
return warmupResponse{}, lastErr
|
||||||
|
}
|
||||||
|
return warmupResponse{}, fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
|
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
|
||||||
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create request for %s: %w", endpoint, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch %s: %w", endpoint, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read %s response: %w", endpoint, err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||||
@@ -539,16 +581,12 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
body, err := readResponseBody(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
return reqURL, nil, responseReadError(ctx, endpoint, err)
|
||||||
if ctx.Err() != nil {
|
|
||||||
return reqURL, nil, err
|
|
||||||
}
|
|
||||||
return reqURL, nil, retryableRequestError{err: err}
|
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
|
||||||
if isRetryableHTTPStatus(resp.StatusCode) {
|
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||||
return reqURL, nil, retryableRequestError{err: err}
|
return reqURL, nil, retryableRequestError{err: err}
|
||||||
}
|
}
|
||||||
@@ -557,6 +595,25 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
|
|||||||
return reqURL, body, nil
|
return reqURL, body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readResponseBody(body io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxResponseBodyBytes {
|
||||||
|
return nil, errResponseBodyTooLarge
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseReadError(ctx context.Context, endpoint string, err error) error {
|
||||||
|
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||||
|
if errors.Is(err, errResponseBodyTooLarge) || ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retryableRequestError{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
type retryableRequestError struct {
|
type retryableRequestError struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -659,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
|||||||
sum := sha256.Sum256(compact.Bytes())
|
sum := sha256.Sum256(compact.Bytes())
|
||||||
return hex.EncodeToString(sum[:]), nil
|
return hex.EncodeToString(sum[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
|
|
||||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
|
||||||
return fmt.Errorf("save bundle: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package weatherapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +17,12 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||||
var requested []string
|
var requested []string
|
||||||
server := fixtureServer(t, nil, &requested)
|
server := fixtureServer(t, nil, &requested)
|
||||||
@@ -80,8 +88,8 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
"/weatherstories/latest",
|
"/weatherstories/latest",
|
||||||
convectiveOutlooksEndpoint,
|
convectiveOutlooksEndpoint,
|
||||||
}
|
}
|
||||||
if len(requested) != len(wantPaths)+1 {
|
if len(requested) != len(wantPaths) {
|
||||||
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||||
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
||||||
@@ -91,6 +99,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
t.Fatalf("requested paths = %v, want %s", requested, want)
|
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if got := countPath(requested, currentConditionsEndpoint); got != 1 {
|
||||||
|
t.Fatalf("conditions/current requests = %d, want 1; requested paths = %v", got, requested)
|
||||||
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
}
|
}
|
||||||
@@ -105,6 +116,191 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchBundleMergesConcurrentSourcesInSourceOrder(t *testing.T) {
|
||||||
|
paths := []string{
|
||||||
|
"/observations",
|
||||||
|
"/forecast/hourly",
|
||||||
|
"/forecast/narrative",
|
||||||
|
"/alerts/active",
|
||||||
|
"/discussion",
|
||||||
|
"/weatherstories/latest",
|
||||||
|
convectiveOutlooksEndpoint,
|
||||||
|
}
|
||||||
|
started := make(chan string, len(paths))
|
||||||
|
release := make(map[string]chan struct{}, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
release[path] = make(chan struct{})
|
||||||
|
}
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
releaseAll := func() {
|
||||||
|
releaseOnce.Do(func() {
|
||||||
|
for i := len(paths) - 1; i >= 0; i-- {
|
||||||
|
close(release[paths[i]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Cleanup(releaseAll)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == currentConditionsEndpoint {
|
||||||
|
if !serveWeatherFixture(w, r) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready, ok := release[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started <- r.URL.Path
|
||||||
|
<-ready
|
||||||
|
if !serveWeatherFixture(w, r) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
type fetchResult struct {
|
||||||
|
bundle *weatherdata.Bundle
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
result := make(chan fetchResult, 1)
|
||||||
|
go func() {
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
result <- fetchResult{bundle: bundle, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
seen := make(map[string]bool, len(paths))
|
||||||
|
for range paths {
|
||||||
|
select {
|
||||||
|
case path := <-started:
|
||||||
|
seen[path] = true
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("independent requests started = %v, want %v", seen, paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releaseAll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-result:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", got.err)
|
||||||
|
}
|
||||||
|
wantSources := []string{
|
||||||
|
config.MissingSourceObservations,
|
||||||
|
config.MissingSourceCurrent,
|
||||||
|
"hourly",
|
||||||
|
config.MissingSourceNarrative,
|
||||||
|
config.MissingSourceAlerts,
|
||||||
|
config.MissingSourceDiscussion,
|
||||||
|
config.MissingSourceWeatherStory,
|
||||||
|
sourceSPCConvectiveOutlooks,
|
||||||
|
}
|
||||||
|
gotSources := make([]string, 0, len(got.bundle.Sources))
|
||||||
|
for _, source := range got.bundle.Sources {
|
||||||
|
gotSources = append(gotSources, source.Name)
|
||||||
|
}
|
||||||
|
if strings.Join(gotSources, ",") != strings.Join(wantSources, ",") {
|
||||||
|
t.Fatalf("source order = %v, want %v", gotSources, wantSources)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("FetchBundle() did not finish after all source responses were released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchBundleReportsConcurrentFailuresInSourceOrder(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {status: http.StatusBadRequest, body: `invalid hourly request`},
|
||||||
|
"/forecast/narrative": {status: http.StatusBadRequest, body: `invalid narrative request`},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want source error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/forecast/hourly") {
|
||||||
|
t.Fatalf("error = %q, want the earlier hourly source failure", err.Error())
|
||||||
|
}
|
||||||
|
if !containsPath(requested, "/forecast/narrative") {
|
||||||
|
t.Fatalf("requested paths = %v, want independent narrative request", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchBundleCancelsConcurrentSourceRequests(t *testing.T) {
|
||||||
|
paths := []string{
|
||||||
|
"/observations",
|
||||||
|
"/forecast/hourly",
|
||||||
|
"/forecast/narrative",
|
||||||
|
"/alerts/active",
|
||||||
|
"/discussion",
|
||||||
|
"/weatherstories/latest",
|
||||||
|
convectiveOutlooksEndpoint,
|
||||||
|
}
|
||||||
|
started := make(chan string, len(paths))
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == currentConditionsEndpoint {
|
||||||
|
if !serveWeatherFixture(w, r) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if r.URL.Path == path {
|
||||||
|
started <- path
|
||||||
|
<-r.Context().Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.FetchBundle(ctx)
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
for range paths {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
cancel()
|
||||||
|
t.Fatal("not all independent requests started before cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("FetchBundle() did not return after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) {
|
||||||
|
for _, probability := range []string{"-1", "101"} {
|
||||||
|
t.Run(probability, func(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
var requested []string
|
var requested []string
|
||||||
server := fixtureServer(t, nil, &requested)
|
server := fixtureServer(t, nil, &requested)
|
||||||
@@ -194,8 +390,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
|
const marker = "upstream-secret-marker"
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
|
"/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)},
|
||||||
}, nil)
|
}, nil)
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
@@ -206,6 +403,9 @@ func TestHTTPErrorIsActionable(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
|
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
|
||||||
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||||
@@ -231,8 +431,8 @@ func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
|||||||
if bundle.Current == nil {
|
if bundle.Current == nil {
|
||||||
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||||
}
|
}
|
||||||
if warmupCalls != 3 {
|
if warmupCalls != 2 {
|
||||||
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls)
|
||||||
}
|
}
|
||||||
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||||
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
||||||
@@ -265,6 +465,111 @@ func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "404") {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err)
|
||||||
|
}
|
||||||
|
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
|
||||||
|
t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested)
|
||||||
|
}
|
||||||
|
if containsPath(requested, "/observations") {
|
||||||
|
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) {
|
||||||
|
const marker = "upstream-secret-marker"
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want warmup error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") {
|
||||||
|
t.Fatalf("error = %q, want warmup endpoint and status", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) {
|
||||||
|
body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes))
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
if _, err := client.FetchBundle(context.Background()); err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
var narrativeCalls int
|
||||||
|
oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x"
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
narrativeCalls++
|
||||||
|
_, _ = w.Write([]byte(oversizedBody))
|
||||||
|
}},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want oversized response error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
|
||||||
|
t.Fatalf("error = %q, want endpoint and response limit", err.Error())
|
||||||
|
}
|
||||||
|
if narrativeCalls != 1 {
|
||||||
|
t.Fatalf("narrative calls = %d, want no retry", narrativeCalls)
|
||||||
|
}
|
||||||
|
if !containsPath(requested, "/alerts/active") {
|
||||||
|
t.Fatalf("requested paths = %v, want independent source requests despite narrative failure", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x"
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(oversizedBody))
|
||||||
|
}},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
client.warmupAttempts = 2
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want oversized warmup response error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
|
||||||
|
t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error())
|
||||||
|
}
|
||||||
|
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
|
||||||
|
t.Fatalf("warmup requests = %d, want no retry; all requests = %v", got, requested)
|
||||||
|
}
|
||||||
|
if containsPath(requested, "/observations") {
|
||||||
|
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
||||||
var hourlyCalls int
|
var hourlyCalls int
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
@@ -312,6 +617,40 @@ func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
requests++
|
||||||
|
return nil, errors.New("unexpected request")
|
||||||
|
})}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
|
||||||
|
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
|
||||||
|
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(tt.baseURL)
|
||||||
|
_, err := New(cfg, WithHTTPClient(httpClient))
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("New() error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if requests != 0 {
|
||||||
|
t.Fatalf("HTTP requests = %d, want none", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
||||||
var hourlyCalls int
|
var hourlyCalls int
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
@@ -351,6 +690,66 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequiredHourlyForecastValidatesPeriodBounds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid period",
|
||||||
|
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing start",
|
||||||
|
body: `{"data":{"periods":[{"endTime":"2026-05-29T14:00:00Z"}]}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing end",
|
||||||
|
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty range",
|
||||||
|
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversed range",
|
||||||
|
body: `{"data":{"periods":[{"startTime":"2026-05-29T14:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {status: http.StatusOK, body: tt.body},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "hourly forecast") || !strings.Contains(err.Error(), "time bounds") {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want hourly time-bounds failure", err)
|
||||||
|
}
|
||||||
|
if got := countPath(requested, "/forecast/hourly"); got != 1 {
|
||||||
|
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
|
||||||
|
t.Fatalf("Hourly = %#v, want accepted hourly period", bundle.Hourly)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
@@ -542,6 +941,44 @@ func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmptyWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
policy config.MissingSourcePolicy
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "warn", policy: config.MissingSourceWarn},
|
||||||
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": tt.policy,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory != nil {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||||
|
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextCancellation(t *testing.T) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -618,48 +1055,40 @@ func TestHTTPTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSaveBundle(t *testing.T) {
|
|
||||||
server := fixtureServer(t, nil, nil)
|
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
|
||||||
bundle, err := client.FetchBundle(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FetchBundle() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
|
|
||||||
if err := SaveBundle(path, bundle); err != nil {
|
|
||||||
t.Fatalf("SaveBundle() error = %v", err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read saved bundle: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), `"hourly"`) {
|
|
||||||
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerOverride struct {
|
type handlerOverride struct {
|
||||||
status int
|
status int
|
||||||
body string
|
body string
|
||||||
handler http.HandlerFunc
|
handler http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var weatherFixtureFiles = map[string]string{
|
||||||
|
"/observations": "observations.json",
|
||||||
|
"/conditions/current": "current.json",
|
||||||
|
"/forecast/hourly": "hourly.json",
|
||||||
|
"/forecast/narrative": "narrative.json",
|
||||||
|
"/alerts/active": "alerts.json",
|
||||||
|
"/discussion": "discussion.json",
|
||||||
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
name, ok := weatherFixtureFiles[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join("testdata", name))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fixtures := map[string]string{
|
var requestedMu sync.Mutex
|
||||||
"/observations": "observations.json",
|
|
||||||
"/conditions/current": "current.json",
|
|
||||||
"/forecast/hourly": "hourly.json",
|
|
||||||
"/forecast/narrative": "narrative.json",
|
|
||||||
"/alerts/active": "alerts.json",
|
|
||||||
"/discussion": "discussion.json",
|
|
||||||
"/weatherstories/latest": "weather_story.json",
|
|
||||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
|
||||||
}
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
requestedMu.Lock()
|
||||||
*requested = append(*requested, r.URL.String())
|
*requested = append(*requested, r.URL.String())
|
||||||
|
requestedMu.Unlock()
|
||||||
}
|
}
|
||||||
if override, ok := overrides[r.URL.Path]; ok {
|
if override, ok := overrides[r.URL.Path]; ok {
|
||||||
if override.handler != nil {
|
if override.handler != nil {
|
||||||
@@ -670,12 +1099,9 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
_, _ = w.Write([]byte(override.body))
|
_, _ = w.Write([]byte(override.body))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name, ok := fixtures[r.URL.Path]
|
if !serveWeatherFixture(w, r) {
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, filepath.Join("testdata", name))
|
|
||||||
}))
|
}))
|
||||||
t.Cleanup(server.Close)
|
t.Cleanup(server.Close)
|
||||||
return server
|
return server
|
||||||
@@ -706,6 +1132,14 @@ func fixedNow() time.Time {
|
|||||||
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func paddedJSON(t *testing.T, value string, size int) string {
|
||||||
|
t.Helper()
|
||||||
|
if len(value) > size {
|
||||||
|
t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size)
|
||||||
|
}
|
||||||
|
return value + strings.Repeat(" ", size-len(value))
|
||||||
|
}
|
||||||
|
|
||||||
func containsPath(requested []string, path string) bool {
|
func containsPath(requested []string, path string) bool {
|
||||||
for _, rawURL := range requested {
|
for _, rawURL := range requested {
|
||||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||||
|
|||||||
@@ -3,23 +3,22 @@ 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/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"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/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/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/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
@@ -43,6 +42,7 @@ const (
|
|||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Report ReportKind
|
Report ReportKind
|
||||||
|
WorkingDir string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
LLMDebugDir string
|
LLMDebugDir string
|
||||||
Now time.Time
|
Now time.Time
|
||||||
@@ -50,29 +50,24 @@ type GenerateRequest struct {
|
|||||||
Collector Collector
|
Collector Collector
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
Executor promptexec.Executor
|
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
|
||||||
|
WorkingDir string
|
||||||
OutputDir string
|
OutputDir string
|
||||||
LLMDebugDir string
|
LLMDebugDir string
|
||||||
Collector Collector
|
Collector Collector
|
||||||
Executor promptexec.Executor
|
Executor promptexec.Executor
|
||||||
Store state.Store
|
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchBundleRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
OutputPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModuleSnapshotRequest struct {
|
type ModuleSnapshotRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
|
Identity briefing.PreparedIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportFacts struct {
|
type ReportFacts struct {
|
||||||
@@ -81,24 +76,22 @@ type ReportFacts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
ModuleSnapshot module.Snapshot
|
ReportID report.ID
|
||||||
ModuleSnapshotPath string
|
ReportName string
|
||||||
DataPackage promptinput.Package
|
PromptID string
|
||||||
DataPackagePath string
|
PromptVersion string
|
||||||
PreparationPath string
|
RunID string
|
||||||
ExecutionPath string
|
GeneratedAt time.Time
|
||||||
LLMDebugPath string
|
Timezone string
|
||||||
ReportPath string
|
ValidPeriod timeutil.Period
|
||||||
OutputPath string
|
ProfileID string
|
||||||
NotificationPath string
|
BackendID string
|
||||||
Metadata state.Metadata
|
ModelName string
|
||||||
MetadataPath string
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
PriorSnapshot *state.PriorSnapshot
|
ValidationStatus promptexec.ValidationStatus
|
||||||
RecentChanges []changes.Change
|
LLMDebugPath string
|
||||||
GeneratedTextRawPath string
|
OutputPath string
|
||||||
GeneratedTextPath string
|
Notification *NotificationResult
|
||||||
RenderContextPath string
|
|
||||||
Notification *NotificationResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
@@ -108,6 +101,7 @@ type BatchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
|
Canceled int `json:"canceled,omitempty"`
|
||||||
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
||||||
Reports []BatchReportResult `json:"reports"`
|
Reports []BatchReportResult `json:"reports"`
|
||||||
}
|
}
|
||||||
@@ -119,7 +113,6 @@ type BatchNotificationResult struct {
|
|||||||
PipelineID string `json:"pipelineId,omitempty"`
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
BundleID string `json:"bundleId,omitempty"`
|
BundleID string `json:"bundleId,omitempty"`
|
||||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
|
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -132,43 +125,51 @@ type BatchNotificationReport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BatchReportResult struct {
|
type BatchReportResult struct {
|
||||||
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"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
NotificationStatus string `json:"notificationStatus,omitempty"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
NotificationRunID string `json:"notificationRunId,omitempty"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
NotificationPipelineID string `json:"notificationPipelineId,omitempty"`
|
Timezone string `json:"timezone"`
|
||||||
NotificationError string `json:"notificationError,omitempty"`
|
ProfileID string `json:"profileId,omitempty"`
|
||||||
NotificationPath string `json:"notificationPath,omitempty"`
|
BackendID string `json:"backendId,omitempty"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
ModelName string `json:"modelName,omitempty"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
ValidationStatus promptexec.ValidationStatus `json:"validationStatus,omitempty"`
|
||||||
PreparationPath string `json:"preparationPath,omitempty"`
|
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||||
ExecutionPath string `json:"executionPath,omitempty"`
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
|
||||||
ReportPath string `json:"reportPath,omitempty"`
|
|
||||||
OutputPath string `json:"outputPath,omitempty"`
|
|
||||||
MetadataPath string `json:"metadataPath,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchError struct {
|
type BatchError struct {
|
||||||
Result *BatchResult
|
Result *BatchResult
|
||||||
|
Cause error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e BatchError) Error() string {
|
func (e BatchError) Error() string {
|
||||||
if e.Result == nil {
|
if e.Result == nil {
|
||||||
return "batch failed"
|
return "batch failed"
|
||||||
}
|
}
|
||||||
if batchNotificationFailed(e.Result) && batchReportFailures(e.Result) == 0 {
|
if errors.Is(e.Cause, context.DeadlineExceeded) {
|
||||||
|
return fmt.Sprintf("batch %s deadline exceeded", e.Result.Batch)
|
||||||
|
}
|
||||||
|
if errors.Is(e.Cause, context.Canceled) || e.Result.Canceled > 0 {
|
||||||
|
return fmt.Sprintf("batch %s canceled", e.Result.Batch)
|
||||||
|
}
|
||||||
|
failedReports := batchReportFailures(e.Result)
|
||||||
|
if batchNotificationFailed(e.Result) && failedReports == 0 {
|
||||||
if e.Result.Notification.Error != "" {
|
if e.Result.Notification.Error != "" {
|
||||||
return fmt.Sprintf("batch %s notification failed: %s", e.Result.Batch, e.Result.Notification.Error)
|
return fmt.Sprintf("batch %s notification failed: %s", e.Result.Batch, e.Result.Notification.Error)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("batch %s notification failed", e.Result.Batch)
|
return fmt.Sprintf("batch %s notification failed", e.Result.Batch)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
|
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, failedReports, len(e.Result.Reports))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e BatchError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
}
|
}
|
||||||
|
|
||||||
func batchNotificationFailed(result *BatchResult) bool {
|
func batchNotificationFailed(result *BatchResult) bool {
|
||||||
@@ -261,21 +262,32 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
result := initialReportResult(req, resolved, PromptInspectionResult{})
|
||||||
if err != nil {
|
if err := preflightDistributorNotification(req.Config); err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return result, err
|
||||||
}
|
}
|
||||||
|
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, req.Config.Output.Directory, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
req.OutputPath = outputPath
|
||||||
|
debugWriter, err := promptdebug.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
|
if err != nil {
|
||||||
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Executor: req.Executor,
|
Executor: req.Executor,
|
||||||
Promptkit: req.Config.Promptkit,
|
Promptkit: req.Config.Promptkit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
result.ProfileID, result.BackendID, result.ModelName = inspection.ProfileID, inspection.BackendID, inspection.ModelName
|
||||||
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 result, err
|
||||||
}
|
}
|
||||||
return generatePromptReport(ctx, promptReportRequest{
|
return generatePromptReport(ctx, promptReportRequest{
|
||||||
GenerateRequest: req,
|
GenerateRequest: req,
|
||||||
@@ -283,6 +295,7 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
Collection: *collection,
|
Collection: *collection,
|
||||||
Inspection: inspection,
|
Inspection: inspection,
|
||||||
DebugWriter: debugWriter,
|
DebugWriter: debugWriter,
|
||||||
|
Result: result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,7 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result.Failed > 0 {
|
if result.Failed > 0 || batchNotificationFailed(result) {
|
||||||
return BatchError{Result: result}
|
return BatchError{Result: result}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -305,10 +318,19 @@ 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 := preflightDistributorNotification(req.Config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outputDir, err := resolveOutputDirWithConfigured(req.WorkingDir, req.OutputDir, req.Config.Output.Directory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.OutputDir = outputDir
|
||||||
|
debugWriter, err := promptdebug.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
candidates, err := batchInspectionCandidates(req, now)
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -329,28 +351,26 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := prepareBatchOutputs(req.OutputDir, plannedReports); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
||||||
store := req.Store
|
|
||||||
if store == nil {
|
|
||||||
defaultStore, err := defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
store = defaultStore
|
|
||||||
}
|
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, planned := range plannedReports {
|
var cancellation error
|
||||||
|
for index, planned := range plannedReports {
|
||||||
|
if cancellation = batchContextCancellationCause(ctx); cancellation != nil {
|
||||||
|
appendCanceledBatchReports(result, plannedReports[index:])
|
||||||
|
break
|
||||||
|
}
|
||||||
resolved := planned.Resolved
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(planned)
|
item := batchReportResult(planned)
|
||||||
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
|
||||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||||
GenerateRequest: GenerateRequest{
|
GenerateRequest: GenerateRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
OutputPath: outputPath,
|
OutputPath: planned.OutputPath,
|
||||||
Notifier: req.Notifier,
|
Notifier: req.Notifier,
|
||||||
Executor: req.Executor,
|
Executor: req.Executor,
|
||||||
Store: store,
|
|
||||||
},
|
},
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Collection: *collection,
|
Collection: *collection,
|
||||||
@@ -359,46 +379,84 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
noNotify: true,
|
noNotify: true,
|
||||||
})
|
})
|
||||||
if reportResult != nil {
|
if reportResult != nil {
|
||||||
copyBatchReportPaths(&item, reportResult)
|
copyBatchReportDetails(&item, reportResult)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
item.Status = "failed"
|
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
|
||||||
item.Error = err.Error()
|
cancellation = reportCancellation
|
||||||
result.Failed++
|
item.Status = "canceled"
|
||||||
|
result.Canceled++
|
||||||
|
} else {
|
||||||
|
item.Status = "failed"
|
||||||
|
item.Error = err.Error()
|
||||||
|
result.Failed++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
item.Status = "succeeded"
|
item.Status = "succeeded"
|
||||||
result.Succeeded++
|
result.Succeeded++
|
||||||
}
|
}
|
||||||
result.Reports = append(result.Reports, item)
|
result.Reports = append(result.Reports, item)
|
||||||
|
if cancellation == nil {
|
||||||
|
cancellation = batchContextCancellationCause(ctx)
|
||||||
|
}
|
||||||
|
if cancellation != nil {
|
||||||
|
appendCanceledBatchReports(result, plannedReports[index+1:])
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.Total = len(result.Reports)
|
result.Total = len(result.Reports)
|
||||||
batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, store, req.Notifier)
|
batchNotification := notifyBatch(batchNotificationInput{
|
||||||
|
ctx: ctx, cancellation: cancellation, cfg: req.Config, batch: req.Batch,
|
||||||
|
runID: batchRunID(startedAt, req.Batch), startedAt: startedAt,
|
||||||
|
result: result, planned: plannedReports, notifier: req.Notifier,
|
||||||
|
})
|
||||||
if batchNotification != nil {
|
if batchNotification != nil {
|
||||||
result.Notification = batchNotification
|
result.Notification = batchNotification
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
result.Failed++
|
|
||||||
}
|
|
||||||
result.FinishedAt = time.Now()
|
result.FinishedAt = time.Now()
|
||||||
return result, nil
|
return result, cancellation
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
|
func batchReportCancellationCause(err error) error {
|
||||||
item.DataPackagePath = result.DataPackagePath
|
if errors.Is(err, context.Canceled) {
|
||||||
item.PreparationPath = result.PreparationPath
|
return context.Canceled
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchContextCancellationCause(ctx context.Context) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCanceledBatchReports(result *BatchResult, plannedReports []plannedBatchReport) {
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, planned := range plannedReports {
|
||||||
|
item := batchReportResult(planned)
|
||||||
|
item.Status = "canceled"
|
||||||
|
result.Reports = append(result.Reports, item)
|
||||||
|
result.Canceled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
|
||||||
|
item.LLMDebugPath = result.LLMDebugPath
|
||||||
|
item.OutputPath = result.OutputPath
|
||||||
|
item.ProfileID = result.ProfileID
|
||||||
|
item.BackendID = result.BackendID
|
||||||
|
item.ModelName = result.ModelName
|
||||||
|
item.Timezone = result.Timezone
|
||||||
|
item.SourceWarnings = append([]weatherdata.SourceWarning(nil), result.SourceWarnings...)
|
||||||
|
item.ValidationStatus = result.ValidationStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||||
@@ -440,23 +498,10 @@ func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
|||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Timezone: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
|
|
||||||
if outputDir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
outputCopyName := planned.OutputCopyName
|
|
||||||
if outputCopyName == "" {
|
|
||||||
outputCopyName = planned.Resolved.Definition.BatchOutputName
|
|
||||||
}
|
|
||||||
if outputCopyName == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(outputDir, outputCopyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -489,14 +534,6 @@ func reportRegistry(cfg config.Config) (report.Registry, error) {
|
|||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
|
||||||
result, err := collectWeather(ctx, req.Config, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result.Bundle, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
|
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
|
||||||
if collector == nil {
|
if collector == nil {
|
||||||
collector = defaultCollector{}
|
collector = defaultCollector{}
|
||||||
@@ -514,137 +551,25 @@ func collectWeather(ctx context.Context, cfg config.Config, collector Collector)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time, notifier Notifier) (*NotificationResult, error) {
|
||||||
if req.OutputPath == "" {
|
notifier, enabled := reportNotifier(cfg, notifier)
|
||||||
return nil, fmt.Errorf("output path is required")
|
if !enabled {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
bundle, err := FetchBundle(ctx, req)
|
notificationRequest, err := buildNotificationRequest(cfg, resolved, outputPath, runID, generatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
|
result, err := notifier.Notify(ctx, notificationRequest)
|
||||||
return nil, fmt.Errorf("save bundle: %w", err)
|
|
||||||
}
|
|
||||||
return bundle, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type finalizeRenderedReportRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
Store state.Store
|
|
||||||
Resolved report.Resolved
|
|
||||||
Metadata state.Metadata
|
|
||||||
MetadataPath string
|
|
||||||
ExecutionArtifact *state.PromptExecutionArtifact
|
|
||||||
ManagedReportPath string
|
|
||||||
OutputPath string
|
|
||||||
Notifier Notifier
|
|
||||||
GenerationErr error
|
|
||||||
noNotify bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type finalizeRenderedReportResult struct {
|
|
||||||
OutputPath string
|
|
||||||
NotificationPath string
|
|
||||||
Metadata state.Metadata
|
|
||||||
MetadataPath string
|
|
||||||
Notification *NotificationResult
|
|
||||||
}
|
|
||||||
|
|
||||||
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
|
|
||||||
if req.Store == nil {
|
|
||||||
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
|
|
||||||
}
|
|
||||||
if req.ManagedReportPath == "" {
|
|
||||||
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
|
|
||||||
}
|
|
||||||
if req.ExecutionArtifact == nil {
|
|
||||||
return finalizeRenderedReportResult{}, fmt.Errorf("prompt execution artifact is required for report %q", req.Resolved.Definition.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := finalizeRenderedReportResult{Metadata: req.Metadata, MetadataPath: req.MetadataPath}
|
|
||||||
if req.OutputPath != "" && req.GenerationErr == nil {
|
|
||||||
if req.OutputPath != req.ManagedReportPath {
|
|
||||||
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
result.OutputPath = req.OutputPath
|
|
||||||
if err := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
|
|
||||||
paths.OutputPath = req.OutputPath
|
|
||||||
}); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.OutputPath = req.OutputPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := req.Metadata
|
|
||||||
metadata.RenderedReportPath = req.ManagedReportPath
|
|
||||||
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, &NotificationError{
|
||||||
}
|
Request: notificationRequest,
|
||||||
result.Metadata = metadata
|
Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, runID, outputPath, err),
|
||||||
result.MetadataPath = metadataPath
|
|
||||||
if req.GenerationErr != nil {
|
|
||||||
return result, req.GenerationErr
|
|
||||||
}
|
|
||||||
if req.noNotify {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
|
||||||
if notificationPath != "" {
|
|
||||||
result.NotificationPath = notificationPath
|
|
||||||
result.Notification = notification
|
|
||||||
metadata.NotificationPath = notificationPath
|
|
||||||
result.Metadata = metadata
|
|
||||||
if saveErr := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
|
|
||||||
paths.NotificationPath = notificationPath
|
|
||||||
}); saveErr != nil {
|
|
||||||
return result, saveErr
|
|
||||||
}
|
}
|
||||||
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
|
|
||||||
if saveErr != nil {
|
|
||||||
return result, saveErr
|
|
||||||
}
|
|
||||||
result.Metadata = metadata
|
|
||||||
result.MetadataPath = metadataPath
|
|
||||||
}
|
|
||||||
result.Notification = notification
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
|
|
||||||
notifier, enabled := reportNotifier(cfg, notifier)
|
|
||||||
if !enabled {
|
|
||||||
return nil, "", nil
|
|
||||||
}
|
|
||||||
notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata)
|
|
||||||
if err != nil {
|
|
||||||
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, NotificationRequest{}, nil, err)
|
|
||||||
if saveErr != nil {
|
|
||||||
return nil, "", saveErr
|
|
||||||
}
|
|
||||||
return nil, notificationPath, err
|
|
||||||
}
|
|
||||||
result, err := notifier.Notify(ctx, notificationRequest)
|
|
||||||
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, notificationRequest, result, err)
|
|
||||||
if saveErr != nil {
|
|
||||||
return nil, "", saveErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return result, notificationPath, &NotificationError{
|
|
||||||
Request: notificationRequest,
|
|
||||||
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, notificationPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
||||||
if !cfg.Notify.Distributor.Enabled {
|
if !cfg.Notify.Distributor.Enabled {
|
||||||
return noopNotifier{}, false
|
return noopNotifier{}, false
|
||||||
@@ -657,8 +582,18 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
func preflightDistributorNotification(cfg config.Config) error {
|
||||||
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
|
if !cfg.Notify.Distributor.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := config.ValidateDistributorEndpoint(cfg.Notify.Distributor.Endpoint); err != nil {
|
||||||
|
return fmt.Errorf("validate notify.distributor.endpoint: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time) (NotificationRequest, error) {
|
||||||
|
values, err := distributorTemplateValuesForReport(cfg, resolved, runID, filepath.Base(outputPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -675,32 +610,36 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, runID, outputPath, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
return NotificationRequest{
|
return NotificationRequest{
|
||||||
ReportID: resolved.Definition.ID,
|
ReportID: resolved.Definition.ID,
|
||||||
RunID: metadata.RunID,
|
RunID: runID,
|
||||||
PipelineID: pipelineID,
|
PipelineID: pipelineID,
|
||||||
BundleID: bundleID,
|
BundleID: bundleID,
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
ReportPath: reportPath,
|
ReportPath: outputPath,
|
||||||
BundlePaths: bundlePaths,
|
BundlePaths: bundlePaths,
|
||||||
CreatedAt: metadata.GeneratedAt,
|
CreatedAt: generatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
|
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, outputName string) (config.DistributorTemplateValues, error) {
|
||||||
values := config.DistributorTemplateValues{
|
values := config.DistributorTemplateValues{
|
||||||
LocationID: cfg.Location.ID,
|
LocationID: cfg.Location.ID,
|
||||||
ReportID: string(resolved.Definition.ID),
|
ReportID: string(resolved.Definition.ID),
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
||||||
BatchOutputName: batchOutputName,
|
BatchOutputName: outputName,
|
||||||
}
|
}
|
||||||
if values.BatchOutputName == "" {
|
if values.BatchOutputName == "" {
|
||||||
values.BatchOutputName = resolved.Definition.BatchOutputName
|
var err error
|
||||||
|
values.BatchOutputName, err = resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return config.DistributorTemplateValues{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -757,54 +696,6 @@ func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, p
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveNotificationArtifact(ctx context.Context, store state.Store, resolved report.Resolved, cfg config.Config, metadata state.Metadata, req NotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
|
||||||
if store == nil {
|
|
||||||
return "", fmt.Errorf("state store is required")
|
|
||||||
}
|
|
||||||
artifact := state.DistributorNotificationArtifact{
|
|
||||||
SchemaVersion: state.DistributorNotificationSchemaVersion,
|
|
||||||
RunID: metadata.RunID,
|
|
||||||
ReportID: resolved.Definition.ID,
|
|
||||||
AttemptedAt: time.Now(),
|
|
||||||
Endpoint: cfg.Notify.Distributor.Endpoint,
|
|
||||||
PipelineID: req.PipelineID,
|
|
||||||
BundleID: req.BundleID,
|
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
|
||||||
SourcePath: req.ReportPath,
|
|
||||||
BundlePaths: append([]string(nil), req.BundlePaths...),
|
|
||||||
BundleCreated: req.CreatedAt,
|
|
||||||
Status: "attempted",
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
artifact.Status = result.Status
|
|
||||||
artifact.Upload = &state.DistributorUploadResult{
|
|
||||||
RunID: result.RunID,
|
|
||||||
Status: result.UploadStatus,
|
|
||||||
}
|
|
||||||
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
|
||||||
artifact.RunStatus = &state.DistributorRunStatus{
|
|
||||||
RunID: result.RunID,
|
|
||||||
PipelineID: result.PipelineID,
|
|
||||||
Status: result.Status,
|
|
||||||
AcceptedAt: result.AcceptedAt,
|
|
||||||
StartedAt: result.StartedAt,
|
|
||||||
FinishedAt: result.FinishedAt,
|
|
||||||
Report: append([]byte(nil), result.Report...),
|
|
||||||
Error: result.Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
artifact.StatusError = result.StatusError
|
|
||||||
}
|
|
||||||
if notifyErr != nil {
|
|
||||||
artifact.Status = "failed"
|
|
||||||
artifact.Error = notifyErr.Error()
|
|
||||||
}
|
|
||||||
if artifact.Status == "" {
|
|
||||||
artifact.Status = "unknown"
|
|
||||||
}
|
|
||||||
return store.SaveDistributorNotification(ctx, resolved, artifact)
|
|
||||||
}
|
|
||||||
|
|
||||||
type noopNotifier struct{}
|
type noopNotifier struct{}
|
||||||
|
|
||||||
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||||
@@ -823,25 +714,7 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
|
|||||||
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
||||||
CreatedAt: req.CreatedAt,
|
CreatedAt: req.CreatedAt,
|
||||||
})
|
})
|
||||||
notification := &NotificationResult{
|
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
|
||||||
PipelineID: req.PipelineID,
|
|
||||||
BundleID: req.BundleID,
|
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
|
||||||
RunID: result.RunID,
|
|
||||||
Status: result.Status,
|
|
||||||
UploadStatus: result.UploadStatus,
|
|
||||||
StatusError: result.StatusError,
|
|
||||||
}
|
|
||||||
if result.RunStatus != nil {
|
|
||||||
if result.RunStatus.PipelineID != "" {
|
|
||||||
notification.PipelineID = result.RunStatus.PipelineID
|
|
||||||
}
|
|
||||||
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
|
||||||
notification.StartedAt = result.RunStatus.StartedAt
|
|
||||||
notification.FinishedAt = result.RunStatus.FinishedAt
|
|
||||||
notification.Report = append([]byte(nil), result.RunStatus.Report...)
|
|
||||||
notification.Error = result.RunStatus.Error
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notification, err
|
return notification, err
|
||||||
}
|
}
|
||||||
@@ -865,7 +738,7 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
|||||||
RunID: result.RunID,
|
RunID: result.RunID,
|
||||||
Status: result.Status,
|
Status: result.Status,
|
||||||
UploadStatus: result.UploadStatus,
|
UploadStatus: result.UploadStatus,
|
||||||
StatusError: result.StatusError,
|
StatusError: safeDistributorStatusError(result.StatusError),
|
||||||
}
|
}
|
||||||
if result.RunStatus != nil {
|
if result.RunStatus != nil {
|
||||||
if result.RunStatus.PipelineID != "" {
|
if result.RunStatus.PipelineID != "" {
|
||||||
@@ -874,12 +747,25 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
|||||||
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
||||||
notification.StartedAt = result.RunStatus.StartedAt
|
notification.StartedAt = result.RunStatus.StartedAt
|
||||||
notification.FinishedAt = result.RunStatus.FinishedAt
|
notification.FinishedAt = result.RunStatus.FinishedAt
|
||||||
notification.Report = append([]byte(nil), result.RunStatus.Report...)
|
notification.Error = safeDistributorRunError(result.RunStatus.Error)
|
||||||
notification.Error = result.RunStatus.Error
|
|
||||||
}
|
}
|
||||||
return notification
|
return notification
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeDistributorStatusError(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "distributor status could not be confirmed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeDistributorRunError(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "distributor reported a failed run"
|
||||||
|
}
|
||||||
|
|
||||||
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
||||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||||
for _, bundlePath := range bundlePaths {
|
for _, bundlePath := range bundlePaths {
|
||||||
@@ -919,7 +805,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return module.Snapshot{}, err
|
return module.Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
identity := req.Identity
|
||||||
|
if identity.ReportID == "" {
|
||||||
|
identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||||
|
}
|
||||||
moduleContext := briefing.ModuleContext{
|
moduleContext := briefing.ModuleContext{
|
||||||
|
Identity: identity,
|
||||||
Resolved: req.Resolved,
|
Resolved: req.Resolved,
|
||||||
Collected: reportFacts.Collected,
|
Collected: reportFacts.Collected,
|
||||||
Derived: reportFacts.Derived,
|
Derived: reportFacts.Derived,
|
||||||
@@ -951,16 +842,16 @@ func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptMetadata(metadata state.Metadata) promptinput.Metadata {
|
func promptMetadata(identity briefing.PreparedIdentity) promptinput.Metadata {
|
||||||
return promptinput.Metadata{
|
return promptinput.Metadata{
|
||||||
RunID: metadata.RunID,
|
RunID: identity.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: identity.ReportID,
|
||||||
Variant: metadata.Variant,
|
Variant: identity.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: identity.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: identity.GeneratedAt,
|
||||||
Timezone: metadata.Timezone,
|
Timezone: identity.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: identity.ValidPeriod,
|
||||||
SourceWarnings: metadata.SourceWarnings,
|
SourceWarnings: identity.SourceWarnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,32 +885,6 @@ func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
|||||||
return &location
|
return &location
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
|
||||||
return state.NewFilesystemStore(cfg.Workspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
|
||||||
if priorSnapshot == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
thresholds := changes.Thresholds{
|
|
||||||
TemperatureDegrees: cfg.TemperatureDegrees,
|
|
||||||
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
|
|
||||||
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
|
|
||||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
|
||||||
}
|
|
||||||
switch reportID {
|
|
||||||
case report.Daily, report.Today, report.Tomorrow:
|
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
|
||||||
default:
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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)
|
|
||||||
361
internal/app/batch_generation_test.go
Normal file
361
internal/app/batch_generation_test.go
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
executor := &generationExecutor{failedPrompt: generationDefinitionForPrompt("weather.tomorrow_generated_text").PromptID}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Canceled != 0 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
|
||||||
|
t.Fatalf("report results = %#v", result.Reports)
|
||||||
|
}
|
||||||
|
if data, readErr := os.ReadFile(result.Reports[0].OutputPath); readErr != nil || len(data) == 0 {
|
||||||
|
t.Fatalf("successful output = %q, error = %v", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
executor := &generationExecutor{cancelBeforeReturn: cancel}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status != "canceled" || item.OutputPath != "" {
|
||||||
|
t.Fatalf("canceled report = %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
executor := &generationExecutor{
|
||||||
|
executeErr: errors.New("independent report failure"),
|
||||||
|
beforeExecute: func(promptexec.ExecuteRequest) {
|
||||||
|
cancel()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
|
||||||
|
t.Fatalf("report results = %#v", result.Reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result := notifyBatch(batchNotificationInput{
|
||||||
|
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
|
||||||
|
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
|
||||||
|
notifier: notifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
|
||||||
|
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
|
||||||
|
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||||
|
t.Run(cause.Error(), func(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
|
||||||
|
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchPreservesCancellationCause(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
err := RunBatch(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("RunBatch() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || notifier.batchCalls != 1 || result.Notification == nil || result.Notification.Status != "succeeded" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
if len(notifier.batchRequest.Files) < 2 || len(notifier.batchRequest.IncludedReports) != 2 {
|
||||||
|
t.Fatalf("batch notification = %#v", notifier.batchRequest)
|
||||||
|
}
|
||||||
|
if result.Reports[0].OutputPath == result.Reports[1].OutputPath {
|
||||||
|
t.Fatalf("batch reports share output path %q", result.Reports[0].OutputPath)
|
||||||
|
}
|
||||||
|
for _, file := range notifier.batchRequest.Files {
|
||||||
|
if filepath.Dir(file.SourcePath) != outputDir || file.BundlePath == "" {
|
||||||
|
t.Fatalf("notification file = %#v", file)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(file.SourcePath); statErr != nil {
|
||||||
|
t.Fatalf("notification source %q: %v", file.SourcePath, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||||
|
Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err == nil || result != nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 || notifier.batchCalls != 0 {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(outputDir)
|
||||||
|
if readErr != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("output directory entries/error = %v/%v", entries, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
directory func(t *testing.T, workingDir string) string
|
||||||
|
wantDir func(t *testing.T, workingDir string, configuredDir string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "working directory default",
|
||||||
|
directory: func(_ *testing.T, _ string) string {
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||||
|
return workingDir
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute directory",
|
||||||
|
directory: func(t *testing.T, _ string) string {
|
||||||
|
return filepath.Join(t.TempDir(), "reports")
|
||||||
|
},
|
||||||
|
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
|
||||||
|
return configuredDir
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative directory",
|
||||||
|
directory: func(_ *testing.T, _ string) string {
|
||||||
|
return "configured/../reports"
|
||||||
|
},
|
||||||
|
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||||
|
return filepath.Join(workingDir, "reports")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
configuredDir := tt.directory(t, workingDir)
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Output.Directory = configuredDir
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
wantDir := tt.wantDir(t, workingDir, configuredDir)
|
||||||
|
if err != nil || result == nil || result.Succeeded != len(result.Reports) || notifier.batchCalls != 1 {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if filepath.Dir(item.OutputPath) != wantDir {
|
||||||
|
t.Fatalf("report output %q, want directory %q", item.OutputPath, wantDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range notifier.batchRequest.Files {
|
||||||
|
if filepath.Dir(file.SourcePath) != wantDir {
|
||||||
|
t.Fatalf("notification source %q, want directory %q", file.SourcePath, wantDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedExplicitOutputDirectoryIgnoresConfiguredDirectory(t *testing.T) {
|
||||||
|
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicitDir := t.TempDir()
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Output.Directory = configuredPath
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: explicitDir,
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{},
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Succeeded != len(result.Reports) {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if filepath.Dir(item.OutputPath) != explicitDir {
|
||||||
|
t.Fatalf("report output %q, want directory %q", item.OutputPath, explicitDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
todayPath := filepath.Join(outputDir, "today.md")
|
||||||
|
const previousReport = "previous report"
|
||||||
|
if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
promptInspectedBeforeCollection := false
|
||||||
|
collector := &generationCollector{
|
||||||
|
bundle: &bundle,
|
||||||
|
beforeRun: func() {
|
||||||
|
promptInspectedBeforeCollection = executor.promptInspections > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||||
|
Collector: collector, Executor: executor, Notifier: &generationNotifier{},
|
||||||
|
})
|
||||||
|
if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called)
|
||||||
|
}
|
||||||
|
if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport {
|
||||||
|
t.Fatalf("earlier output = %q, error = %v", data, readErr)
|
||||||
|
}
|
||||||
|
if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("blocked output info/error = %#v/%v", info, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedRetainsReportCountsWhenNotificationFails(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
notifier := &generationNotifier{batchErr: errors.New("distributor unavailable")}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Total != len(result.Reports) || result.Succeeded != len(result.Reports) || result.Failed != 0 || result.Notification == nil || result.Notification.Status != "failed" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status != "succeeded" || item.OutputPath == "" {
|
||||||
|
t.Fatalf("report result = %#v", item)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(item.OutputPath); statErr != nil {
|
||||||
|
t.Fatalf("published output %q: %v", item.OutputPath, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchReturnsNotificationFailureWithoutReportFailureWording(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
err := RunBatch(context.Background(), BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{batchErr: errors.New("distributor unavailable")},
|
||||||
|
})
|
||||||
|
var batchErr BatchError
|
||||||
|
if !errors.As(err, &batchErr) || batchErr.Result == nil || batchErr.Result.Failed != 0 || batchErr.Result.Notification == nil || batchErr.Result.Notification.Status != "failed" || !strings.Contains(err.Error(), "notification failed") || strings.Contains(err.Error(), "reports failed") {
|
||||||
|
t.Fatalf("RunBatch() error/result = %v/%#v", err, batchErr.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationDistributorConfig() config.Config {
|
||||||
|
cfg := generationConfig()
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,62 +42,67 @@ type batchNotifier interface {
|
|||||||
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type batchNotificationInput struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancellation error
|
||||||
|
cfg config.Config
|
||||||
|
batch BatchKind
|
||||||
|
runID string
|
||||||
|
startedAt time.Time
|
||||||
|
result *BatchResult
|
||||||
|
planned []plannedBatchReport
|
||||||
|
notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||||
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) {
|
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
|
||||||
if !cfg.Notify.Distributor.Enabled {
|
if !input.cfg.Notify.Distributor.Enabled {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
if !input.cfg.Notify.Distributor.Batch.Enabled {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
if result == nil {
|
if input.result == nil {
|
||||||
return nil, fmt.Errorf("batch result is required")
|
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
||||||
}
|
}
|
||||||
if result.Failed > 0 {
|
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
|
||||||
|
return &BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "batch canceled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.result.Failed > 0 {
|
||||||
return &BatchNotificationResult{
|
return &BatchNotificationResult{
|
||||||
Status: "skipped",
|
Status: "skipped",
|
||||||
Reason: "one or more reports failed",
|
Reason: "one or more reports failed",
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
|
||||||
if err != nil {
|
|
||||||
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
|
|
||||||
if saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
}
|
||||||
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
|
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||||
if saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
|
||||||
return failedBatchNotificationResult(req, path, err), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
|
||||||
|
if err != nil {
|
||||||
|
return failedBatchNotificationResult(req, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
|
||||||
wrappedErr := notifyErr
|
wrappedErr := notifyErr
|
||||||
if notifyErr != nil {
|
if notifyErr != nil {
|
||||||
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
|
||||||
}
|
}
|
||||||
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr)
|
batchResult := batchNotificationResult(req, notification)
|
||||||
if saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
|
||||||
|
|
||||||
batchResult := batchNotificationResult(req, notification, path)
|
|
||||||
if wrappedErr != nil {
|
if wrappedErr != nil {
|
||||||
batchResult.Status = "failed"
|
batchResult.Status = "failed"
|
||||||
batchResult.Error = wrappedErr.Error()
|
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
|
||||||
return batchResult, wrappedErr
|
return batchResult
|
||||||
}
|
}
|
||||||
return batchResult, nil
|
return batchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
|
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
|
||||||
@@ -153,15 +158,15 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
|
|||||||
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
if item.ReportPath == "" {
|
if item.OutputPath == "" {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID)
|
||||||
}
|
}
|
||||||
|
|
||||||
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
|
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err)
|
||||||
}
|
}
|
||||||
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchNotificationRequest{}, err
|
return batchNotificationRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -169,18 +174,18 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
|
|||||||
included := BatchNotificationReport{
|
included := BatchNotificationReport{
|
||||||
ReportID: item.ReportID,
|
ReportID: item.ReportID,
|
||||||
RunID: item.RunID,
|
RunID: item.RunID,
|
||||||
SourcePath: item.ReportPath,
|
SourcePath: item.OutputPath,
|
||||||
BundlePaths: append([]string(nil), bundlePaths...),
|
BundlePaths: append([]string(nil), bundlePaths...),
|
||||||
}
|
}
|
||||||
for _, bundlePath := range bundlePaths {
|
for _, bundlePath := range bundlePaths {
|
||||||
file := batchNotificationFile{
|
file := batchNotificationFile{
|
||||||
ReportID: item.ReportID,
|
ReportID: item.ReportID,
|
||||||
RunID: item.RunID,
|
RunID: item.RunID,
|
||||||
SourcePath: item.ReportPath,
|
SourcePath: item.OutputPath,
|
||||||
BundlePath: bundlePath,
|
BundlePath: bundlePath,
|
||||||
}
|
}
|
||||||
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
||||||
}
|
}
|
||||||
seenBundlePaths[bundlePath] = file
|
seenBundlePaths[bundlePath] = file
|
||||||
req.Files = append(req.Files, file)
|
req.Files = append(req.Files, file)
|
||||||
@@ -225,13 +230,12 @@ func batchDistributorUploadRequest(req batchNotificationRequest) distributoradap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult {
|
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult) *BatchNotificationResult {
|
||||||
notification := &BatchNotificationResult{
|
notification := &BatchNotificationResult{
|
||||||
Status: "unknown",
|
Status: "unknown",
|
||||||
PipelineID: req.PipelineID,
|
PipelineID: req.PipelineID,
|
||||||
BundleID: req.BundleID,
|
BundleID: req.BundleID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Path: path,
|
|
||||||
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
||||||
}
|
}
|
||||||
if result != nil {
|
if result != nil {
|
||||||
@@ -247,7 +251,7 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
|
|||||||
notification.IdempotencyKey = result.IdempotencyKey
|
notification.IdempotencyKey = result.IdempotencyKey
|
||||||
}
|
}
|
||||||
if result.Error != "" {
|
if result.Error != "" {
|
||||||
notification.Error = result.Error
|
notification.Error = safeDistributorRunError(result.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if notification.Status == "" {
|
if notification.Status == "" {
|
||||||
@@ -256,85 +260,20 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
|
|||||||
return notification
|
return notification
|
||||||
}
|
}
|
||||||
|
|
||||||
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
|
func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
|
||||||
notification := batchNotificationResult(req, nil, path)
|
notification := batchNotificationResult(req, nil)
|
||||||
notification.Status = "failed"
|
notification.Status = "failed"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notification.Error = err.Error()
|
notification.Error = safeDistributorNotificationFailure(err)
|
||||||
}
|
}
|
||||||
return notification
|
return notification
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
func safeDistributorNotificationFailure(err error) string {
|
||||||
if store == nil {
|
if err == nil {
|
||||||
return "", fmt.Errorf("state store is required")
|
return ""
|
||||||
}
|
}
|
||||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
return "distributor notification failed"
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("load batch notification timezone: %w", err)
|
|
||||||
}
|
|
||||||
artifact := state.BatchDistributorNotificationArtifact{
|
|
||||||
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
|
|
||||||
Batch: string(batch),
|
|
||||||
BatchRunID: runID,
|
|
||||||
AttemptedAt: time.Now(),
|
|
||||||
Endpoint: cfg.Notify.Distributor.Endpoint,
|
|
||||||
PipelineID: req.PipelineID,
|
|
||||||
BundleID: req.BundleID,
|
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
|
||||||
BundleCreated: req.CreatedAt,
|
|
||||||
Reports: batchNotificationReportArtifacts(req.IncludedReports),
|
|
||||||
Status: "attempted",
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
artifact.Status = result.Status
|
|
||||||
artifact.Upload = &state.DistributorUploadResult{
|
|
||||||
RunID: result.RunID,
|
|
||||||
Status: result.UploadStatus,
|
|
||||||
}
|
|
||||||
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
|
||||||
artifact.RunStatus = &state.DistributorRunStatus{
|
|
||||||
RunID: result.RunID,
|
|
||||||
PipelineID: result.PipelineID,
|
|
||||||
Status: result.Status,
|
|
||||||
AcceptedAt: result.AcceptedAt,
|
|
||||||
StartedAt: result.StartedAt,
|
|
||||||
FinishedAt: result.FinishedAt,
|
|
||||||
Report: append([]byte(nil), result.Report...),
|
|
||||||
Error: result.Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
artifact.StatusError = result.StatusError
|
|
||||||
}
|
|
||||||
if notifyErr != nil {
|
|
||||||
artifact.Status = "failed"
|
|
||||||
artifact.Error = notifyErr.Error()
|
|
||||||
}
|
|
||||||
if artifact.Status == "" {
|
|
||||||
artifact.Status = "unknown"
|
|
||||||
}
|
|
||||||
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
|
|
||||||
Batch: string(batch),
|
|
||||||
BatchRunID: runID,
|
|
||||||
StartedAt: startedAt,
|
|
||||||
Location: location,
|
|
||||||
}, artifact)
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
|
|
||||||
if len(reports) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
|
|
||||||
for _, item := range reports {
|
|
||||||
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
|
|
||||||
ReportID: item.ReportID,
|
|
||||||
RunID: item.RunID,
|
|
||||||
SourcePath: item.SourcePath,
|
|
||||||
BundlePaths: append([]string(nil), item.BundlePaths...),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return artifacts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type plannedBatchReport struct {
|
type plannedBatchReport struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
OutputCopyName string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||||
@@ -36,16 +36,16 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
var planned []plannedBatchReport
|
var planned []plannedBatchReport
|
||||||
switch batch {
|
switch batch {
|
||||||
case report.Morning:
|
case report.Morning:
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
case report.Evening:
|
case report.Evening:
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -60,8 +60,7 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
for _, date := range eligibleDailyDates(hourly, now, location) {
|
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||||
dailyReq := resolveReq
|
dailyReq := resolveReq
|
||||||
dailyReq.Date = date
|
dailyReq.Date = date
|
||||||
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq)
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,15 +68,12 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
return planned, nil
|
return planned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) {
|
||||||
resolved, err := registry.Resolve(id, req)
|
resolved, err := registry.Resolve(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return append(planned, plannedBatchReport{
|
return append(planned, plannedBatchReport{Resolved: resolved}), nil
|
||||||
Resolved: resolved,
|
|
||||||
OutputCopyName: outputCopyName,
|
|
||||||
}), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
|||||||
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
func TestPlanBatchRunUsesResolvedOutputNames(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)...)
|
||||||
|
|
||||||
@@ -68,11 +68,19 @@ func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
|||||||
if len(daily) != 1 {
|
if len(daily) != 1 {
|
||||||
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||||
}
|
}
|
||||||
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
outputName, err := daily[0].Resolved.OutputName()
|
||||||
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
if err != nil {
|
||||||
|
t.Fatalf("OutputName() error = %v", err)
|
||||||
}
|
}
|
||||||
if planned[0].OutputCopyName != "" {
|
if outputName != "daily-2026-05-31.md" {
|
||||||
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
|
||||||
|
}
|
||||||
|
outputName, err = planned[0].Resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OutputName() error = %v", err)
|
||||||
|
}
|
||||||
|
if outputName != "tomorrow.md" {
|
||||||
|
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
237
internal/app/comparison.go
Normal file
237
internal/app/comparison.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComparisonRequest describes one explicit, multi-profile report comparison.
|
||||||
|
// It deliberately does not accept a notifier: comparison publication is local.
|
||||||
|
type ComparisonRequest struct {
|
||||||
|
Config config.Config
|
||||||
|
Report ReportKind
|
||||||
|
ProfileIDs []string
|
||||||
|
WorkingDir string
|
||||||
|
OutputDir string
|
||||||
|
Replace bool
|
||||||
|
LLMDebugDir string
|
||||||
|
Date time.Time
|
||||||
|
Clock timeutil.Clock
|
||||||
|
Collector Collector
|
||||||
|
Executor promptexec.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparisonResult records the resolved comparison and profile outcomes.
|
||||||
|
type ComparisonResult struct {
|
||||||
|
ComparisonID string
|
||||||
|
ReportID report.ID
|
||||||
|
ReportName string
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
Timezone string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
OutputDirectory string
|
||||||
|
ManifestPath string
|
||||||
|
DataPackagePath string
|
||||||
|
Total int
|
||||||
|
Succeeded int
|
||||||
|
Failed int
|
||||||
|
Results []ComparisonProfileResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparisonProfileResult records one explicitly selected profile.
|
||||||
|
type ComparisonProfileResult struct {
|
||||||
|
Position int
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
Status string
|
||||||
|
ValidationStatus promptexec.ValidationStatus
|
||||||
|
ReportPath string
|
||||||
|
LLMDebugPath string
|
||||||
|
Error *comparison.SafeError
|
||||||
|
}
|
||||||
|
|
||||||
|
type comparisonPublisher func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error)
|
||||||
|
|
||||||
|
// CompareDetailed assembles, executes, and atomically publishes a comparison
|
||||||
|
// bundle. Profile failures publish a complete partial bundle. Failures before
|
||||||
|
// commit leave the destination untouched; a post-commit cleanup failure leaves
|
||||||
|
// the new bundle installed and returns its artifact paths with an error.
|
||||||
|
func CompareDetailed(ctx context.Context, req ComparisonRequest) (*ComparisonResult, error) {
|
||||||
|
return compareDetailed(ctx, req, comparison.Publish)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareDetailed(ctx context.Context, req ComparisonRequest, publish comparisonPublisher) (*ComparisonResult, error) {
|
||||||
|
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
clock := req.Clock
|
||||||
|
if clock == nil {
|
||||||
|
clock = timeutil.SystemClock{}
|
||||||
|
}
|
||||||
|
now := clock.Now()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{Config: req.Config, Report: req.Report, Date: req.Date}, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
metadata := resolved.Metadata()
|
||||||
|
comparisonID, err := comparison.BuildComparisonID(metadata.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build comparison identity: %w", err)
|
||||||
|
}
|
||||||
|
result := initialComparisonResult(req, resolved, comparisonID, now.UTC())
|
||||||
|
defer func() {
|
||||||
|
if result.FinishedAt.IsZero() {
|
||||||
|
finalizeComparisonResult(result, clock)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
outputName, err := resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("resolve comparison output name: %w", err)
|
||||||
|
}
|
||||||
|
outputDirectory, err := resolveComparisonOutputDirectory(req.WorkingDir, req.OutputDir, req.Config.Output.Directory, outputName)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.OutputDirectory = outputDirectory
|
||||||
|
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("preflight comparison destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
debugWriter, err := promptdebug.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
|
if err != nil {
|
||||||
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
|
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
||||||
|
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
|
||||||
|
})
|
||||||
|
result.PromptID, result.PromptVersion, result.PromptHash = inspection.PromptID, inspection.PromptVersion, inspection.PromptHash
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection, handler: inspection.handler})
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("prepare comparison report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
executed := executeComparisonProfiles(ctx, comparisonExecutionRequest{
|
||||||
|
Prepared: prepared, Inspection: inspection, ComparisonID: comparisonID, DebugWriter: debugWriter, Executor: req.Executor,
|
||||||
|
})
|
||||||
|
finalizeComparisonResult(result, clock)
|
||||||
|
copyComparisonOutcomes(result, executed.Outcomes, false)
|
||||||
|
if executed.Canceled {
|
||||||
|
return result, fmt.Errorf("comparison execution: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle := comparisonBundle(result, prepared.dataPackageCopy(), executed.Outcomes)
|
||||||
|
if err := bundle.Validate(); err != nil {
|
||||||
|
return result, fmt.Errorf("build comparison bundle: %w", err)
|
||||||
|
}
|
||||||
|
publication, err := publish(ctx, publicationPlan, bundle)
|
||||||
|
if publication.Committed {
|
||||||
|
result.OutputDirectory = publicationPlan.Target
|
||||||
|
result.ManifestPath = filepath.Join(publicationPlan.Target, comparison.ManifestFilename)
|
||||||
|
result.DataPackagePath = filepath.Join(publicationPlan.Target, comparison.DataPackageFilename)
|
||||||
|
copyComparisonOutcomes(result, executed.Outcomes, true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("publish comparison bundle: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return result, fmt.Errorf("comparison completed with %d failed profiles", result.Failed)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeComparisonResult(result *ComparisonResult, clock timeutil.Clock) {
|
||||||
|
finishedAt := clock.Now().UTC()
|
||||||
|
if finishedAt.IsZero() {
|
||||||
|
finishedAt = time.Unix(0, 1).UTC()
|
||||||
|
}
|
||||||
|
if finishedAt.Before(result.StartedAt) {
|
||||||
|
finishedAt = result.StartedAt
|
||||||
|
}
|
||||||
|
result.FinishedAt = finishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialComparisonResult(req ComparisonRequest, resolved report.Resolved, comparisonID string, startedAt time.Time) *ComparisonResult {
|
||||||
|
metadata := resolved.Metadata()
|
||||||
|
return &ComparisonResult{
|
||||||
|
ComparisonID: comparisonID,
|
||||||
|
ReportID: resolved.Definition.ID,
|
||||||
|
ReportName: resolved.Definition.Name,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyComparisonOutcomes(result *ComparisonResult, outcomes []comparisonProfileOutcome, published bool) {
|
||||||
|
result.Results = make([]ComparisonProfileResult, len(outcomes))
|
||||||
|
result.Total, result.Succeeded, result.Failed = len(outcomes), 0, 0
|
||||||
|
for index, outcome := range outcomes {
|
||||||
|
profile := ComparisonProfileResult{
|
||||||
|
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
|
||||||
|
Status: outcome.Status, ValidationStatus: outcome.ValidationStatus, LLMDebugPath: outcome.LLMDebugPath, Error: outcome.Error,
|
||||||
|
}
|
||||||
|
if published && outcome.Status == comparison.StatusSucceeded {
|
||||||
|
profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath)
|
||||||
|
}
|
||||||
|
result.Results[index] = profile
|
||||||
|
if outcome.Status == comparison.StatusSucceeded {
|
||||||
|
result.Succeeded++
|
||||||
|
} else {
|
||||||
|
result.Failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonBundle(result *ComparisonResult, dataPackage []byte, outcomes []comparisonProfileOutcome) comparison.LogicalBundle {
|
||||||
|
manifest := comparison.Manifest{
|
||||||
|
SchemaVersion: comparison.SchemaVersion, ComparisonID: result.ComparisonID,
|
||||||
|
StartedAt: result.StartedAt.UTC(), FinishedAt: result.FinishedAt.UTC(),
|
||||||
|
ReportID: string(result.ReportID), Timezone: result.Timezone,
|
||||||
|
ValidPeriod: comparison.ValidPeriod{Start: result.ValidPeriod.Start, End: result.ValidPeriod.End},
|
||||||
|
PromptID: result.PromptID, PromptVersion: result.PromptVersion, PromptHash: result.PromptHash,
|
||||||
|
DataPackage: comparison.DataPackageReference{Path: comparison.DataPackageFilename, SHA256: comparison.SHA256(dataPackage)},
|
||||||
|
Total: result.Total, Succeeded: result.Succeeded, Failed: result.Failed,
|
||||||
|
Results: make([]comparison.Result, len(outcomes)),
|
||||||
|
}
|
||||||
|
bundle := comparison.LogicalBundle{Manifest: manifest, DataPackage: dataPackage}
|
||||||
|
for index, outcome := range outcomes {
|
||||||
|
manifestResult := comparison.Result{
|
||||||
|
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
|
||||||
|
Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error,
|
||||||
|
}
|
||||||
|
if outcome.Status == comparison.StatusSucceeded {
|
||||||
|
manifestResult.ReportPath = outcome.ReportPath
|
||||||
|
bundle.Reports = append(bundle.Reports, comparison.BundleReport{Position: outcome.Position, Path: outcome.ReportPath, Markdown: outcome.Markdown})
|
||||||
|
}
|
||||||
|
bundle.Manifest.Results[index] = manifestResult
|
||||||
|
}
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
177
internal/app/comparison_execution.go
Normal file
177
internal/app/comparison_execution.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type comparisonExecutionRequest struct {
|
||||||
|
Prepared preparedReport
|
||||||
|
Inspection ComparisonInspectionResult
|
||||||
|
ComparisonID string
|
||||||
|
DebugWriter *promptdebug.PromptDebugWriter
|
||||||
|
Executor promptexec.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
type comparisonExecutionResult struct {
|
||||||
|
Outcomes []comparisonProfileOutcome
|
||||||
|
Canceled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type comparisonProfileOutcome struct {
|
||||||
|
Position int
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
Status string
|
||||||
|
ValidationStatus promptexec.ValidationStatus
|
||||||
|
ReportPath string
|
||||||
|
Markdown []byte
|
||||||
|
LLMDebugPath string
|
||||||
|
Error *comparison.SafeError
|
||||||
|
canceled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type comparisonProfileExecutionState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
comparisonProfilePending comparisonProfileExecutionState = iota
|
||||||
|
comparisonProfileRunning
|
||||||
|
comparisonProfileComplete
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
|
||||||
|
profiles := req.Inspection.Profiles
|
||||||
|
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
|
||||||
|
states := make([]comparisonProfileExecutionState, len(profiles))
|
||||||
|
for index, profile := range profiles {
|
||||||
|
result.Outcomes[index] = comparisonProfileOutcome{
|
||||||
|
Position: index + 1,
|
||||||
|
ProfileID: profile.ProfileID,
|
||||||
|
BackendID: profile.BackendID,
|
||||||
|
ModelName: profile.ModelName,
|
||||||
|
Status: comparison.StatusFailed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for index, profile := range profiles {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.Canceled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
index, profile := index, profile
|
||||||
|
states[index] = comparisonProfileRunning
|
||||||
|
waitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
|
||||||
|
states[index] = comparisonProfileComplete
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.Canceled = true
|
||||||
|
for index := range result.Outcomes {
|
||||||
|
if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
|
||||||
|
markCanceledComparisonOutcome(&result.Outcomes[index], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeComparisonProfile(ctx context.Context, req comparisonExecutionRequest, index int, profile ComparisonProfileInspection) comparisonProfileOutcome {
|
||||||
|
position := index + 1
|
||||||
|
outcome := comparisonProfileOutcome{
|
||||||
|
Position: position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
|
Status: comparison.StatusFailed,
|
||||||
|
}
|
||||||
|
debugRef := promptdebug.PromptDebugRef{
|
||||||
|
ReportID: req.Prepared.resolved.Definition.ID,
|
||||||
|
ValidDate: req.Prepared.resolved.ValidPeriod.Start.Format("2006-01-02"),
|
||||||
|
RunID: comparisonDebugRunID(req.ComparisonID, position, len(req.Inspection.Profiles), profile.ProfileID),
|
||||||
|
}
|
||||||
|
execution, markdown, err := executePreparedProfile(ctx, profileExecutionRequest{
|
||||||
|
Prepared: req.Prepared,
|
||||||
|
Prompt: PromptInspectionResult{
|
||||||
|
PromptID: req.Inspection.PromptID, PromptVersion: req.Inspection.PromptVersion, PromptHash: req.Inspection.PromptHash,
|
||||||
|
},
|
||||||
|
Profile: promptexec.ProfileInspection{ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName},
|
||||||
|
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
|
||||||
|
})
|
||||||
|
outcome.ProfileID, outcome.BackendID, outcome.ModelName = execution.ProfileID, execution.BackendID, execution.ModelName
|
||||||
|
outcome.ValidationStatus = execution.ValidationStatus
|
||||||
|
outcome.LLMDebugPath = execution.LLMDebugPath
|
||||||
|
if err != nil {
|
||||||
|
outcome.canceled = cancellationError(err)
|
||||||
|
safe := comparisonSafeExecutionError(err)
|
||||||
|
outcome.Error = &safe
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
reportPath, err := comparison.ReportFilename(position, len(req.Inspection.Profiles), profile.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
safe := comparison.NewSafeError("application", "derive comparison report filename failed")
|
||||||
|
outcome.Error = &safe
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
outcome.Status = comparison.StatusSucceeded
|
||||||
|
outcome.ReportPath = reportPath
|
||||||
|
outcome.Markdown = append([]byte(nil), markdown...)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonDebugRunID(comparisonID string, position, profileCount int, profileID string) string {
|
||||||
|
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
|
||||||
|
outcome.Status = comparison.StatusFailed
|
||||||
|
outcome.ValidationStatus = promptexec.ValidationSkipped
|
||||||
|
outcome.ReportPath = ""
|
||||||
|
outcome.Markdown = nil
|
||||||
|
safe := comparisonSafeExecutionError(err)
|
||||||
|
outcome.Error = &safe
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancellationError(err error) bool {
|
||||||
|
category := promptexec.CategoryOf(err)
|
||||||
|
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||||
|
category == promptexec.Canceled || category == promptexec.DeadlineExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonSafeExecutionError(err error) comparison.SafeError {
|
||||||
|
category := promptexec.CategoryOf(err)
|
||||||
|
if category == "" {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
category = promptexec.Canceled
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
category = promptexec.DeadlineExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if category == "" {
|
||||||
|
return comparison.NewSafeError("application", comparisonExecutionMessage(err))
|
||||||
|
}
|
||||||
|
return comparison.NewSafeError(string(category), comparisonExecutionMessage(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonExecutionMessage(err error) string {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return "profile execution canceled"
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "profile execution deadline exceeded"
|
||||||
|
}
|
||||||
|
var execution *profileExecutionError
|
||||||
|
if errors.As(err, &execution) {
|
||||||
|
return comparison.TruncateErrorMessage(execution.operation + " failed")
|
||||||
|
}
|
||||||
|
return "profile execution failed"
|
||||||
|
}
|
||||||
320
internal/app/comparison_execution_test.go
Normal file
320
internal/app/comparison_execution_test.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T) {
|
||||||
|
prepared, prompt := preparedDailyProfile(t)
|
||||||
|
profiles := comparisonProfiles(10)
|
||||||
|
executor := newBarrierExecutor(profiles)
|
||||||
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
|
}, executor)
|
||||||
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
|
if executor.maximumInFlight() < 2 {
|
||||||
|
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
|
||||||
|
}
|
||||||
|
for index := len(profiles) - 1; index >= 0; index-- {
|
||||||
|
executor.release(profiles[index].ProfileID)
|
||||||
|
}
|
||||||
|
result := <-results
|
||||||
|
if result.Canceled || len(result.Outcomes) != len(profiles) {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
for index, profile := range profiles {
|
||||||
|
outcome := result.Outcomes[index]
|
||||||
|
wantPath, err := comparison.ReportFilename(index+1, len(profiles), profile.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if outcome.Position != index+1 || outcome.ProfileID != profile.ProfileID || outcome.Status != comparison.StatusSucceeded || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ReportPath != wantPath || len(outcome.Markdown) == 0 || outcome.Error != nil {
|
||||||
|
t.Fatalf("outcome[%d] = %#v", index, outcome)
|
||||||
|
}
|
||||||
|
request, ok := executor.request(profile.ProfileID)
|
||||||
|
if !ok || request.PromptVersion != prompt.PromptVersion || !bytesEqual(request.DataPackage, prepared.dataPackage) {
|
||||||
|
t.Fatalf("request for %q = %#v, want prompt version %q and shared data package", profile.ProfileID, request, prompt.PromptVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
|
||||||
|
prepared, prompt := preparedDailyProfile(t)
|
||||||
|
profiles := comparisonProfiles(3)
|
||||||
|
executor := newBarrierExecutor(profiles)
|
||||||
|
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
|
||||||
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
|
}, executor)
|
||||||
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
|
for _, profile := range profiles {
|
||||||
|
executor.release(profile.ProfileID)
|
||||||
|
}
|
||||||
|
result := <-results
|
||||||
|
if result.Canceled || result.Outcomes[0].Status != comparison.StatusSucceeded || result.Outcomes[1].Status != comparison.StatusFailed || result.Outcomes[2].Status != comparison.StatusSucceeded {
|
||||||
|
t.Fatalf("outcomes = %#v", result.Outcomes)
|
||||||
|
}
|
||||||
|
failure := result.Outcomes[1]
|
||||||
|
if failure.Error == nil || failure.Error.Category != string(promptexec.Generation) || failure.Error.Message != "execute prompt failed" || failure.ReportPath != "" || len(failure.Markdown) != 0 {
|
||||||
|
t.Fatalf("failure outcome = %#v", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteComparisonProfilesPropagatesCancellationAndJoins(t *testing.T) {
|
||||||
|
prepared, prompt := preparedDailyProfile(t)
|
||||||
|
profiles := comparisonProfiles(4)
|
||||||
|
executor := newBarrierExecutor(profiles)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
results := startComparisonExecution(t, ctx, comparisonExecutionRequest{
|
||||||
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
|
}, executor)
|
||||||
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
|
cancel()
|
||||||
|
result := <-results
|
||||||
|
if !result.Canceled || executor.inFlightCount() != 0 {
|
||||||
|
t.Fatalf("result/in-flight = %#v/%d", result, executor.inFlightCount())
|
||||||
|
}
|
||||||
|
for _, outcome := range result.Outcomes {
|
||||||
|
if outcome.Status != comparison.StatusFailed || outcome.Error == nil || outcome.Error.Category != string(promptexec.Canceled) || outcome.ValidationStatus != promptexec.ValidationSkipped || outcome.ReportPath != "" || len(outcome.Markdown) != 0 {
|
||||||
|
t.Fatalf("canceled outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *testing.T) {
|
||||||
|
prepared, prompt := preparedDailyProfile(t)
|
||||||
|
profiles := []ComparisonProfileInspection{
|
||||||
|
{ProfileID: "light.one", BackendID: "local", ModelName: "light"},
|
||||||
|
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
|
||||||
|
}
|
||||||
|
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||||
|
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||||
|
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
executor := newBarrierExecutor(profiles)
|
||||||
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
||||||
|
}, executor)
|
||||||
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
|
for _, profile := range profiles {
|
||||||
|
executor.release(profile.ProfileID)
|
||||||
|
}
|
||||||
|
result := <-results
|
||||||
|
paths := map[string]struct{}{}
|
||||||
|
for index, outcome := range result.Outcomes {
|
||||||
|
wantName := fmt.Sprintf("comparison_daily_%0*d-%s", comparison.OrdinalWidth(len(profiles)), index+1, comparison.ProfileSlug(outcome.ProfileID))
|
||||||
|
if filepath.Base(outcome.LLMDebugPath) != wantName {
|
||||||
|
t.Fatalf("debug path = %q, want base %q", outcome.LLMDebugPath, wantName)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outcome.LLMDebugPath, "preparation.json")); err != nil {
|
||||||
|
t.Fatalf("preparation artifact %q: %v", outcome.LLMDebugPath, err)
|
||||||
|
}
|
||||||
|
paths[outcome.LLMDebugPath] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(paths) != len(profiles) {
|
||||||
|
t.Fatalf("debug paths = %#v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type barrierExecutor struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
started chan string
|
||||||
|
callbackFailures chan error
|
||||||
|
releases map[string]chan struct{}
|
||||||
|
requests map[string]promptexec.ExecuteRequest
|
||||||
|
errors map[string]error
|
||||||
|
profiles map[string]ComparisonProfileInspection
|
||||||
|
inFlight int
|
||||||
|
maximum int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
|
||||||
|
releases := make(map[string]chan struct{}, len(profiles))
|
||||||
|
identities := make(map[string]ComparisonProfileInspection, len(profiles))
|
||||||
|
for _, profile := range profiles {
|
||||||
|
releases[profile.ProfileID] = make(chan struct{})
|
||||||
|
identities[profile.ProfileID] = profile
|
||||||
|
}
|
||||||
|
return &barrierExecutor{
|
||||||
|
started: make(chan string, len(profiles)), callbackFailures: make(chan error, len(profiles)), releases: releases,
|
||||||
|
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{}, profiles: identities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||||
|
return promptexec.ProfileInspection{}, errors.New("unexpected profile inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
e.mu.Lock()
|
||||||
|
profile := e.profiles[req.ProfileID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||||
|
e.callbackFailures <- err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.requests[req.ProfileID] = promptexec.ExecuteRequest{PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, DataPackage: append([]byte(nil), req.DataPackage...), CaptureDebug: req.CaptureDebug}
|
||||||
|
e.inFlight++
|
||||||
|
if e.inFlight > e.maximum {
|
||||||
|
e.maximum = e.inFlight
|
||||||
|
}
|
||||||
|
release := e.releases[req.ProfileID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.started <- req.ProfileID
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-ctx.Done():
|
||||||
|
e.mu.Lock()
|
||||||
|
e.inFlight--
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.inFlight--
|
||||||
|
err := e.errors[req.ProfileID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &promptexec.Execution{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash,
|
||||||
|
ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
|
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
|
||||||
|
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) request(profileID string) (promptexec.ExecuteRequest, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
request, ok := e.requests[profileID]
|
||||||
|
return request, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) setError(profileID string, err error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.errors[profileID] = err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) release(profileID string) {
|
||||||
|
close(e.releases[profileID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) releaseAll() {
|
||||||
|
for _, release := range e.releases {
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
default:
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) maximumInFlight() int {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *barrierExecutor) inFlightCount() int {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.inFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisonExecutionTestTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func startComparisonExecution(t *testing.T, ctx context.Context, request comparisonExecutionRequest, executor *barrierExecutor) <-chan comparisonExecutionResult {
|
||||||
|
t.Helper()
|
||||||
|
results := make(chan comparisonExecutionResult, 1)
|
||||||
|
finished := make(chan struct{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
executor.releaseAll()
|
||||||
|
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||||
|
defer timeout.Stop()
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
case <-timeout.C:
|
||||||
|
t.Error("comparison execution workers did not finish after release")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
defer close(finished)
|
||||||
|
results <- executeComparisonProfiles(ctx, request)
|
||||||
|
}()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProfileStarts(t *testing.T, executor *barrierExecutor, profiles []ComparisonProfileInspection, results <-chan comparisonExecutionResult) {
|
||||||
|
t.Helper()
|
||||||
|
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||||
|
defer timeout.Stop()
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for range profiles {
|
||||||
|
var profileID string
|
||||||
|
select {
|
||||||
|
case profileID = <-executor.started:
|
||||||
|
case err := <-executor.callbackFailures:
|
||||||
|
executor.releaseAll()
|
||||||
|
select {
|
||||||
|
case result := <-results:
|
||||||
|
t.Fatalf("comparison profile preparation failed before executor entry: %v; result: %#v", err, result)
|
||||||
|
case <-timeout.C:
|
||||||
|
t.Fatalf("comparison profile preparation failed before executor entry: %v; comparison did not finish", err)
|
||||||
|
}
|
||||||
|
case result := <-results:
|
||||||
|
t.Fatalf("comparison completed before all profiles started: %#v", result)
|
||||||
|
case <-timeout.C:
|
||||||
|
t.Fatal("timed out waiting for comparison profile starts")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[profileID]; duplicate {
|
||||||
|
t.Fatalf("duplicate execution start for %q", profileID)
|
||||||
|
}
|
||||||
|
seen[profileID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonProfiles(count int) []ComparisonProfileInspection {
|
||||||
|
profiles := make([]ComparisonProfileInspection, 0, count)
|
||||||
|
for index := 1; index <= count; index++ {
|
||||||
|
profiles = append(profiles, ComparisonProfileInspection{ProfileID: fmt.Sprintf("profile.%02d", index), BackendID: "backend", ModelName: "model"})
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonInspection(prompt PromptInspectionResult, profiles []ComparisonProfileInspection) ComparisonInspectionResult {
|
||||||
|
return ComparisonInspectionResult{PromptID: prompt.PromptID, PromptVersion: prompt.PromptVersion, PromptHash: prompt.PromptHash, Profiles: profiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonRawOutput() []byte {
|
||||||
|
return []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."}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesEqual(left, right []byte) bool {
|
||||||
|
return reflect.DeepEqual(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ promptexec.Executor = (*barrierExecutor)(nil)
|
||||||
399
internal/app/comparison_test.go
Normal file
399
internal/app/comparison_test.go
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) {
|
||||||
|
cfg := comparisonConfig()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
inspectedBeforeCollection := false
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle, beforeRun: func() {
|
||||||
|
inspectedBeforeCollection = executor.promptInspections == 1 && executor.profileInspections == 2
|
||||||
|
}}, Executor: executor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || executor.promptInspections != 1 || executor.profileInspections != 2 || executor.executeCalls != 2 || !inspectedBeforeCollection || result.ManifestPath == "" || result.DataPackagePath == "" {
|
||||||
|
t.Fatalf("result/executor = %#v/%#v", result, executor)
|
||||||
|
}
|
||||||
|
if result.OutputDirectory != filepath.Dir(result.ManifestPath) || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||||
|
t.Fatalf("published paths = %#v", result)
|
||||||
|
}
|
||||||
|
for index, profile := range result.Results {
|
||||||
|
if profile.Position != index+1 || profile.Status != comparison.StatusSucceeded || !filepath.IsAbs(profile.ReportPath) || profile.Error != nil {
|
||||||
|
t.Fatalf("profile result = %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(result.ManifestPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
var manifest comparison.Manifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if manifest.ComparisonID != result.ComparisonID || manifest.Total != result.Total || manifest.Succeeded != result.Succeeded || manifest.DataPackage.SHA256 == "" || len(manifest.Results) != 2 {
|
||||||
|
t.Fatalf("manifest = %#v", manifest)
|
||||||
|
}
|
||||||
|
if manifest.Results[0].ReportPath != filepath.Base(result.Results[0].ReportPath) || manifest.Results[1].ReportPath != filepath.Base(result.Results[1].ReportPath) {
|
||||||
|
t.Fatalf("manifest report paths = %#v", manifest.Results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testing.T) {
|
||||||
|
cfg := comparisonConfig()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
executor := &generationExecutor{executeErrors: map[string]error{"weather-deep": errors.New("provider detail must not escape")}}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep", "weather-fallback"},
|
||||||
|
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || err.Error() != "comparison completed with 1 failed profiles" || result == nil || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
failure := result.Results[1]
|
||||||
|
if failure.Status != comparison.StatusFailed || failure.ReportPath != "" || failure.Error == nil || strings.Contains(failure.Error.Message, "provider detail") {
|
||||||
|
t.Fatalf("failure = %#v", failure)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(result.ManifestPath); statErr != nil {
|
||||||
|
t.Fatalf("partial manifest: %v", statErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(result.OutputDirectory, filepath.Base(result.Results[0].ReportPath))); statErr != nil {
|
||||||
|
t.Fatalf("successful partial report: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
state comparison.BackupRecoveryState
|
||||||
|
path bool
|
||||||
|
}{
|
||||||
|
{name: "complete recovery bundle", state: comparison.BackupRecoveryComplete, path: true},
|
||||||
|
{name: "partial remnants", state: comparison.BackupRecoveryPartial, path: true},
|
||||||
|
{name: "absent backup", state: comparison.BackupRecoveryAbsent},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
recoveryPath := ""
|
||||||
|
if test.path {
|
||||||
|
recoveryPath = filepath.Join(t.TempDir(), ".comparison-daily.backup-recovery")
|
||||||
|
}
|
||||||
|
cleanupCause := errors.New("backup cleanup failed")
|
||||||
|
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
|
||||||
|
return comparison.PublicationResult{Committed: true, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := compareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
|
}, publish)
|
||||||
|
var cleanupErr *comparison.PublicationCleanupError
|
||||||
|
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RecoveryState != test.state || cleanupErr.RecoveryPath != recoveryPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
for _, profile := range result.Results {
|
||||||
|
if profile.Status == comparison.StatusSucceeded && !filepath.IsAbs(profile.ReportPath) {
|
||||||
|
t.Fatalf("published profile result = %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
|
||||||
|
invalidDestination := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(invalidDestination, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: t.TempDir(), OutputDir: invalidDestination, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.executeCalls != 0 || result.ManifestPath != "" {
|
||||||
|
t.Fatalf("result/error/collector/executor = %#v/%v/%#v/%#v", result, err, collector, executor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedFinalizesUnpublishedFailures(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
prepare func(t *testing.T, outputDirectory string)
|
||||||
|
debugDir string
|
||||||
|
executor *generationExecutor
|
||||||
|
collector *generationCollector
|
||||||
|
wantPrompt bool
|
||||||
|
wantCollection bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "destination preflight",
|
||||||
|
prepare: func(t *testing.T, outputDirectory string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(outputDirectory, []byte("not a directory"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
executor: &generationExecutor{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug initialization",
|
||||||
|
debugDir: "relative-debug-directory",
|
||||||
|
executor: &generationExecutor{},
|
||||||
|
collector: &generationCollector{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prompt preflight",
|
||||||
|
executor: &generationExecutor{inspectErr: promptexec.NewError(promptexec.PromptLoad, "unsafe prompt detail", errors.New("unsafe cause"))},
|
||||||
|
collector: &generationCollector{},
|
||||||
|
wantPrompt: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profile preflight",
|
||||||
|
executor: &generationExecutor{profileInspectErrors: map[string]error{
|
||||||
|
"weather-deep": promptexec.NewError(promptexec.MissingCredential, "profile credential is unavailable", errors.New("unsafe cause")),
|
||||||
|
}},
|
||||||
|
collector: &generationCollector{},
|
||||||
|
wantPrompt: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collection",
|
||||||
|
executor: &generationExecutor{},
|
||||||
|
collector: &generationCollector{err: errors.New("collection failed")},
|
||||||
|
wantPrompt: true,
|
||||||
|
wantCollection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preparation",
|
||||||
|
executor: &generationExecutor{},
|
||||||
|
collector: &generationCollector{bundle: &weatherdata.Bundle{}},
|
||||||
|
wantPrompt: true,
|
||||||
|
wantCollection: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
workingDirectory := t.TempDir()
|
||||||
|
outputDirectory := filepath.Join(workingDirectory, "comparison-output")
|
||||||
|
if test.prepare != nil {
|
||||||
|
test.prepare(t, outputDirectory)
|
||||||
|
}
|
||||||
|
collector := test.collector
|
||||||
|
if collector == nil {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector = &generationCollector{bundle: &bundle}
|
||||||
|
}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: workingDirectory, OutputDir: outputDirectory, LLMDebugDir: test.debugDir,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: collector, Executor: test.executor,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompareDetailed() error = nil")
|
||||||
|
}
|
||||||
|
assertUnpublishedComparisonResult(t, result, outputDirectory)
|
||||||
|
if (result.PromptID != "") != test.wantPrompt || (result.PromptHash != "") != test.wantPrompt {
|
||||||
|
t.Fatalf("prompt identity = %q/%q, want resolved=%t", result.PromptID, result.PromptHash, test.wantPrompt)
|
||||||
|
}
|
||||||
|
if collector.called != test.wantCollection || test.executor.executeCalls != 0 {
|
||||||
|
t.Fatalf("collection/execution = %t/%d, want collection=%t and no execution", collector.called, test.executor.executeCalls, test.wantCollection)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedLeavesDestinationWhenCollectionOrPreparationFails(t *testing.T) {
|
||||||
|
collectionErr := errors.New("weather collection failed")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
collector *generationCollector
|
||||||
|
}{
|
||||||
|
{name: "collection", collector: &generationCollector{err: collectionErr}},
|
||||||
|
{name: "preparation", collector: &generationCollector{bundle: &weatherdata.Bundle{}}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: test.collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || executor.executeCalls != 0 || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||||
|
t.Fatalf("CompareDetailed() result/error/executor = %#v/%v/%#v", result, err, executor)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(workingDir, "comparison-daily-2026-05-29")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("comparison destination stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
executor := &generationExecutor{executeErrors: map[string]error{
|
||||||
|
"weather-light": errors.New("first provider failure"), "weather-deep": errors.New("second provider failure"),
|
||||||
|
}}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || err.Error() != "comparison completed with 2 failed profiles" || result == nil || result.Succeeded != 0 || result.Failed != 2 || result.ManifestPath == "" {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
for _, profile := range result.Results {
|
||||||
|
if profile.ReportPath != "" || profile.Error == nil {
|
||||||
|
t.Fatalf("failed profile = %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
request := ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
|
}
|
||||||
|
previous, err := CompareDetailed(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initial CompareDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
before, err := os.ReadFile(previous.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
request.Replace = true
|
||||||
|
request.Executor = &generationExecutor{cancelBeforeReturn: cancel}
|
||||||
|
result, err := CompareDetailed(ctx, request)
|
||||||
|
if !errors.Is(err, context.Canceled) || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||||
|
t.Fatalf("canceled CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
after, readErr := os.ReadFile(previous.ManifestPath)
|
||||||
|
if readErr != nil || string(after) != string(before) {
|
||||||
|
t.Fatalf("published manifest changed = %q, error = %v", after, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
failureStarted := make(chan struct{})
|
||||||
|
var signalFailure sync.Once
|
||||||
|
executor := &generationExecutor{
|
||||||
|
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
|
||||||
|
waitForCancellation: map[string]bool{"weather-deep": true},
|
||||||
|
beforeExecute: func(request promptexec.ExecuteRequest) {
|
||||||
|
if request.ProfileID == "weather-light" {
|
||||||
|
signalFailure.Do(func() { close(failureStarted) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results := make(chan struct {
|
||||||
|
result *ComparisonResult
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
go func() {
|
||||||
|
result, err := CompareDetailed(ctx, ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||||
|
})
|
||||||
|
results <- struct {
|
||||||
|
result *ComparisonResult
|
||||||
|
err error
|
||||||
|
}{result: result, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-failureStarted:
|
||||||
|
cancel()
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the completed profile failure")
|
||||||
|
}
|
||||||
|
completed := <-results
|
||||||
|
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
|
||||||
|
}
|
||||||
|
failed, canceled := completed.result.Results[0], completed.result.Results[1]
|
||||||
|
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
|
||||||
|
t.Fatalf("completed failure = %#v", failed)
|
||||||
|
}
|
||||||
|
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
|
||||||
|
t.Fatalf("canceled profile = %#v", canceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
target := filepath.Join(workingDir, "comparison-output")
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
executor := &generationExecutor{beforeExecute: func(promptexec.ExecuteRequest) {
|
||||||
|
_ = os.WriteFile(target, []byte("changed"), 0o600)
|
||||||
|
}}
|
||||||
|
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: workingDir, OutputDir: target, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" || result.Results[0].ReportPath != "" {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(target)
|
||||||
|
if readErr != nil || string(data) != "changed" {
|
||||||
|
t.Fatalf("destination = %q, error = %v", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonConfig() config.Config {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUnpublishedComparisonResult(t *testing.T, result *ComparisonResult, outputDirectory string) {
|
||||||
|
t.Helper()
|
||||||
|
if result == nil || result.OutputDirectory != outputDirectory || !filepath.IsAbs(result.OutputDirectory) || result.FinishedAt.IsZero() || result.FinishedAt.Location() != time.UTC || result.FinishedAt.Before(result.StartedAt) || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||||
|
t.Fatalf("unpublished comparison result = %#v", result)
|
||||||
|
}
|
||||||
|
for _, profile := range result.Results {
|
||||||
|
if profile.ReportPath != "" {
|
||||||
|
t.Fatalf("unpublished profile result = %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/app/distributor_notification_test.go
Normal file
45
internal/app/distributor_notification_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotificationResultFromUploadExcludesRemoteResponseDetails(t *testing.T) {
|
||||||
|
const remote = "REMOTE-DIAGNOSTIC"
|
||||||
|
notification := notificationResultFromUpload("weather", "bundle", "key", distributoradapter.UploadResult{
|
||||||
|
RunID: "run-123", Status: "failed", UploadStatus: "accepted", StatusError: remote,
|
||||||
|
RunStatus: &distributoradapter.RunStatus{PipelineID: "weather", Status: "failed", Report: []byte(`{"detail":"REMOTE-DIAGNOSTIC"}`), Error: remote},
|
||||||
|
})
|
||||||
|
if notification == nil || notification.StatusError != "distributor status could not be confirmed" || notification.Error != "distributor reported a failed run" || len(notification.Report) != 0 {
|
||||||
|
t.Fatalf("notification = %#v", notification)
|
||||||
|
}
|
||||||
|
if strings.Contains(notification.StatusError, remote) || strings.Contains(notification.Error, remote) {
|
||||||
|
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
|
||||||
|
const remote = "REMOTE-DIAGNOSTIC"
|
||||||
|
notification := batchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, &NotificationResult{Status: "failed", Error: remote})
|
||||||
|
if notification == nil || notification.Error != "distributor reported a failed run" {
|
||||||
|
t.Fatalf("notification = %#v", notification)
|
||||||
|
}
|
||||||
|
if strings.Contains(notification.Error, remote) {
|
||||||
|
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
|
||||||
|
const remote = "REMOTE-DIAGNOSTIC"
|
||||||
|
notification := failedBatchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, errors.New(remote))
|
||||||
|
if notification == nil || notification.Error != "distributor notification failed" {
|
||||||
|
t.Fatalf("notification = %#v", notification)
|
||||||
|
}
|
||||||
|
if strings.Contains(notification.Error, remote) {
|
||||||
|
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
609
internal/app/generation_test.go
Normal file
609
internal/app/generation_test.go
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generationCollector struct {
|
||||||
|
bundle *weatherdata.Bundle
|
||||||
|
err error
|
||||||
|
called bool
|
||||||
|
calls int
|
||||||
|
beforeRun func()
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicationGateContext struct {
|
||||||
|
context.Context
|
||||||
|
err error
|
||||||
|
checks int
|
||||||
|
afterChecks int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *publicationGateContext) Err() error {
|
||||||
|
c.checks++
|
||||||
|
afterChecks := c.afterChecks
|
||||||
|
if afterChecks == 0 {
|
||||||
|
afterChecks = 2
|
||||||
|
}
|
||||||
|
if c.checks >= afterChecks {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
|
if c.beforeRun != nil {
|
||||||
|
c.beforeRun()
|
||||||
|
}
|
||||||
|
c.called = true
|
||||||
|
c.calls++
|
||||||
|
return &collect.Result{Bundle: c.bundle}, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type generationExecutor struct {
|
||||||
|
called bool
|
||||||
|
executeCalls int
|
||||||
|
promptInspections int
|
||||||
|
profileInspections int
|
||||||
|
inspectErr error
|
||||||
|
profileInspectErrors map[string]error
|
||||||
|
executeErr error
|
||||||
|
executeErrors map[string]error
|
||||||
|
beforeExecute func(promptexec.ExecuteRequest)
|
||||||
|
cancelBeforeReturn context.CancelFunc
|
||||||
|
validation promptexec.ValidationStatus
|
||||||
|
validations map[string]promptexec.ValidationStatus
|
||||||
|
rawOutput []byte
|
||||||
|
waitForCancellation map[string]bool
|
||||||
|
failedPrompt string
|
||||||
|
skipPreparation bool
|
||||||
|
preparationCalls int
|
||||||
|
prepare func(*promptexec.Preparation)
|
||||||
|
complete func(*promptexec.Execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
var generationExecutorMu sync.Mutex
|
||||||
|
|
||||||
|
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||||
|
generationExecutorMu.Lock()
|
||||||
|
defer generationExecutorMu.Unlock()
|
||||||
|
e.promptInspections++
|
||||||
|
if e.inspectErr != nil {
|
||||||
|
return promptexec.PromptInspection{}, e.inspectErr
|
||||||
|
}
|
||||||
|
definition := generationDefinitionForPrompt(id)
|
||||||
|
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: generationPromptHash, DefaultProfileID: "fixture", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"}}, nil
|
||||||
|
}
|
||||||
|
func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
generationExecutorMu.Lock()
|
||||||
|
defer generationExecutorMu.Unlock()
|
||||||
|
e.profileInspections++
|
||||||
|
if err := e.profileInspectErrors[id]; err != nil {
|
||||||
|
return promptexec.ProfileInspection{}, err
|
||||||
|
}
|
||||||
|
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||||
|
}
|
||||||
|
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
generationExecutorMu.Lock()
|
||||||
|
skipPreparation := e.skipPreparation
|
||||||
|
prepare := e.prepare
|
||||||
|
preparationCalls := e.preparationCalls
|
||||||
|
generationExecutorMu.Unlock()
|
||||||
|
if !skipPreparation {
|
||||||
|
calls := preparationCalls
|
||||||
|
if calls == 0 {
|
||||||
|
calls = 1
|
||||||
|
}
|
||||||
|
for range calls {
|
||||||
|
preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}
|
||||||
|
if prepare != nil {
|
||||||
|
prepare(&preparation)
|
||||||
|
}
|
||||||
|
if err := callback(preparation, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generationExecutorMu.Lock()
|
||||||
|
e.called = true
|
||||||
|
e.executeCalls++
|
||||||
|
beforeExecute := e.beforeExecute
|
||||||
|
profileErr := e.executeErrors[req.ProfileID]
|
||||||
|
executeErr := e.executeErr
|
||||||
|
status := e.validation
|
||||||
|
if profileStatus, ok := e.validations[req.ProfileID]; ok {
|
||||||
|
status = profileStatus
|
||||||
|
}
|
||||||
|
rawOutput := append([]byte(nil), e.rawOutput...)
|
||||||
|
waitForCancellation := e.waitForCancellation[req.ProfileID]
|
||||||
|
failedPrompt := e.failedPrompt
|
||||||
|
cancelBeforeReturn := e.cancelBeforeReturn
|
||||||
|
complete := e.complete
|
||||||
|
generationExecutorMu.Unlock()
|
||||||
|
if beforeExecute != nil {
|
||||||
|
beforeExecute(req)
|
||||||
|
}
|
||||||
|
if waitForCancellation {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
if profileErr != nil {
|
||||||
|
return nil, profileErr
|
||||||
|
}
|
||||||
|
if executeErr != nil {
|
||||||
|
return nil, executeErr
|
||||||
|
}
|
||||||
|
if status == "" {
|
||||||
|
status = promptexec.ValidationPassed
|
||||||
|
}
|
||||||
|
if failedPrompt == req.PromptID {
|
||||||
|
status = promptexec.ValidationFailed
|
||||||
|
}
|
||||||
|
if rawOutput == nil {
|
||||||
|
rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
|
||||||
|
}
|
||||||
|
if cancelBeforeReturn != nil {
|
||||||
|
cancelBeforeReturn()
|
||||||
|
}
|
||||||
|
execution := &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}
|
||||||
|
if complete != nil {
|
||||||
|
complete(execution)
|
||||||
|
}
|
||||||
|
return execution, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func generationDefinitionForPrompt(promptID string) report.Definition {
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
if definition.PromptID == promptID {
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("unknown fixture prompt " + promptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: collector, Executor: executor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if !executor.called || executor.executeCalls != 1 || collector.calls != 1 || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
if result.LLMDebugPath != "" {
|
||||||
|
t.Fatalf("unexpected debug output = %q", result.LLMDebugPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(workingDir, "workspace")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("unexpected default state directory: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(result.OutputPath)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
t.Fatalf("output = %q, error = %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedUsesConfiguredOutputDirectory(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
directory func(t *testing.T, workingDir string) string
|
||||||
|
wantDir func(t *testing.T, workingDir string, configuredDir string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absolute directory",
|
||||||
|
directory: func(t *testing.T, _ string) string {
|
||||||
|
return filepath.Join(t.TempDir(), "reports")
|
||||||
|
},
|
||||||
|
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
|
||||||
|
return configuredDir
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative directory",
|
||||||
|
directory: func(_ *testing.T, _ string) string {
|
||||||
|
return "configured/../reports"
|
||||||
|
},
|
||||||
|
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||||
|
return filepath.Join(workingDir, "reports")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
configuredDir := tt.directory(t, workingDir)
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Output.Directory = configuredDir
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
wantPath := filepath.Join(tt.wantDir(t, workingDir, configuredDir), "daily-2026-05-29.md")
|
||||||
|
if err != nil || result == nil || result.OutputPath != wantPath || notifier.request.ReportPath != wantPath {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/notification = %#v/%v/%#v", result, err, notifier.request)
|
||||||
|
}
|
||||||
|
if info, statErr := os.Stat(filepath.Dir(wantPath)); statErr != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("configured output directory info/error = %#v/%v", info, statErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(wantPath); statErr != nil {
|
||||||
|
t.Fatalf("output %q: %v", wantPath, statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory(t *testing.T) {
|
||||||
|
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicitPath := filepath.Join(t.TempDir(), "explicit.md")
|
||||||
|
cfg := generationConfig()
|
||||||
|
cfg.Output.Directory = configuredPath
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: explicitPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.OutputPath != explicitPath {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(explicitPath); statErr != nil {
|
||||||
|
t.Fatalf("explicit output %q: %v", explicitPath, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
|
||||||
|
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Output.Directory = configuredPath
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
if data, readErr := os.ReadFile(configuredPath); readErr != nil || string(data) != "not a directory" {
|
||||||
|
t.Fatalf("configured path = %q, error = %v", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) {
|
||||||
|
missingDirectory := filepath.Join(t.TempDir(), "missing")
|
||||||
|
outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md")
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: generationConfig(), Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("missing output directory exists after preflight failure: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
cfg := generationDistributorConfig()
|
||||||
|
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("output exists after endpoint preflight failure: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||||
|
collectionErr := errors.New("weather source unavailable")
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), Collector: &generationCollector{err: collectionErr}, Executor: &generationExecutor{},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, collectionErr) {
|
||||||
|
t.Fatalf("GenerateDetailed() error = %v, want %v", err, collectionErr)
|
||||||
|
}
|
||||||
|
if result == nil || result.ReportID != report.Daily || result.RunID == "" || result.ProfileID != "fixture" || result.BackendID != "fixture" || result.ModelName != "fixture-model" || result.OutputPath != "" {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedInspectsPromptBeforeCollectingWeather(t *testing.T) {
|
||||||
|
cfg := generationConfig()
|
||||||
|
inspectionErr := errors.New("profile is invalid")
|
||||||
|
collector := &generationCollector{bundle: generationBundlePointer(t)}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: &generationExecutor{inspectErr: inspectionErr}})
|
||||||
|
if !errors.Is(err, inspectionErr) || collector.called || result == nil {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/collector-called = %#v/%v/%t", result, err, collector.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
|
||||||
|
for _, scenario := range []struct {
|
||||||
|
name string
|
||||||
|
executor generationExecutor
|
||||||
|
}{
|
||||||
|
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
|
||||||
|
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
|
||||||
|
} {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
|
||||||
|
data, readErr := os.ReadFile(outputPath)
|
||||||
|
if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
const previousReport = "previous report"
|
||||||
|
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||||
|
Config: generationConfig(), Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
|
||||||
|
})
|
||||||
|
data, readErr := os.ReadFile(outputPath)
|
||||||
|
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
const previousReport = "previous report"
|
||||||
|
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
|
||||||
|
defer cancel()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||||
|
Config: generationConfig(), Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
|
})
|
||||||
|
data, readErr := os.ReadFile(outputPath)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
category promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{name: "canceled", err: context.Canceled, category: promptexec.Canceled},
|
||||||
|
{name: "deadline", err: context.DeadlineExceeded, category: promptexec.DeadlineExceeded},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
const previousReport = "previous report"
|
||||||
|
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := &publicationGateContext{Context: context.Background(), err: tt.err}
|
||||||
|
cfg := generationConfig()
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
data, readErr := os.ReadFile(outputPath)
|
||||||
|
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(outputPath), ".weatherreporter-*.tmp"))
|
||||||
|
if !errors.Is(err, tt.err) || promptexec.CategoryOf(err) != tt.category || result == nil || result.OutputPath != "" || notifier.calls != 0 || readErr != nil || string(data) != previousReport || globErr != nil || len(matches) != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v", result, err, data, notifier, matches, globErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
|
||||||
|
cfg := generationConfig()
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
notifier := &generationNotifier{err: errors.New("distributor unavailable")}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier})
|
||||||
|
if err == nil || result == nil || result.OutputPath != outputPath || notifier.request.ReportPath != outputPath || len(notifier.request.BundlePaths) == 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/request = %#v/%v/%#v", result, err, notifier.request)
|
||||||
|
}
|
||||||
|
if data, readErr := os.ReadFile(outputPath); readErr != nil || len(data) == 0 {
|
||||||
|
t.Fatalf("published output = %q, error = %v", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
if err := os.Mkdir(outputPath, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
|
||||||
|
info, statErr := os.Stat(outputPath)
|
||||||
|
if err == nil || result == nil || statErr != nil || !info.IsDir() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
backing := filepath.Join(dir, "backing.md")
|
||||||
|
if err := os.WriteFile(backing, []byte("previous report"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(dir, "daily.md")
|
||||||
|
testutil.RequireSymlink(t, backing, outputPath)
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
|
||||||
|
info, statErr := os.Lstat(outputPath)
|
||||||
|
data, readErr := os.ReadFile(backing)
|
||||||
|
if err == nil || result == nil || statErr != nil || info.Mode()&os.ModeSymlink == 0 || readErr != nil || string(data) != "previous report" || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v", result, err, info, statErr, data, readErr, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
debugRoot := t.TempDir()
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: generationConfig(), Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
|
})
|
||||||
|
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||||
|
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil || result == nil || result.LLMDebugPath == "" {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"preparation.json", "execution.json"} {
|
||||||
|
if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil {
|
||||||
|
t.Fatalf("debug artifact %q: %v", name, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationConfig() config.Config {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationBundlePointer(t *testing.T) *weatherdata.Bundle {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
return &bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
type generationNotifier struct {
|
||||||
|
err error
|
||||||
|
batchErr error
|
||||||
|
calls int
|
||||||
|
request NotificationRequest
|
||||||
|
batchRequest batchNotificationRequest
|
||||||
|
batchCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.calls++
|
||||||
|
n.request = request
|
||||||
|
if n.err != nil {
|
||||||
|
return nil, n.err
|
||||||
|
}
|
||||||
|
return &NotificationResult{Status: "succeeded"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *generationNotifier) NotifyBatch(_ context.Context, request batchNotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.batchCalls++
|
||||||
|
n.batchRequest = request
|
||||||
|
for _, file := range request.Files {
|
||||||
|
if _, err := os.Stat(file.SourcePath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &NotificationResult{Status: "succeeded", PipelineID: request.PipelineID, BundleID: request.BundleID}, n.batchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationBundle(t *testing.T) weatherdata.Bundle {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bundle fixture: %v", err)
|
||||||
|
}
|
||||||
|
var bundle weatherdata.Bundle
|
||||||
|
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||||
|
t.Fatalf("decode bundle fixture: %v", err)
|
||||||
|
}
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
func generationTime(value string) time.Time {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, value)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ promptexec.Executor = (*generationExecutor)(nil)
|
||||||
|
var _ Collector = (*generationCollector)(nil)
|
||||||
|
var _ Notifier = (*generationNotifier)(nil)
|
||||||
|
var _ = report.Daily
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type InspectReportsRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
type InspectRunRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
RunID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourceInspection struct {
|
|
||||||
RunID string `json:"runId"`
|
|
||||||
ReportID report.ID `json:"reportId"`
|
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
|
||||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
|
||||||
store, err := defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return store.ListReports(ctx, req.Limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
|
||||||
inspection, err := inspectRun(ctx, req)
|
|
||||||
return inspection.metadata, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
|
|
||||||
inspection, err := inspectRun(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return module.Snapshot{}, err
|
|
||||||
}
|
|
||||||
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
|
||||||
inspection, err := inspectRun(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return promptinput.Package{}, err
|
|
||||||
}
|
|
||||||
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
|
||||||
inspection, err := inspectRun(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolved, err := resolvedFromMetadata(inspection.metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
|
||||||
inspection, err := inspectRun(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return SourceInspection{}, err
|
|
||||||
}
|
|
||||||
metadata := inspection.metadata
|
|
||||||
return SourceInspection{
|
|
||||||
RunID: metadata.RunID,
|
|
||||||
ReportID: metadata.ReportID,
|
|
||||||
SourceLocation: metadata.SourceLocation,
|
|
||||||
Sources: metadata.Sources,
|
|
||||||
Warnings: metadata.SourceWarnings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type runInspection struct {
|
|
||||||
store *state.FilesystemStore
|
|
||||||
metadata state.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
|
|
||||||
store, err := defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return runInspection{}, err
|
|
||||||
}
|
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
|
||||||
if err != nil {
|
|
||||||
return runInspection{}, err
|
|
||||||
}
|
|
||||||
return runInspection{store: store, metadata: metadata}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
|
||||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
|
||||||
if err != nil {
|
|
||||||
return report.Resolved{}, err
|
|
||||||
}
|
|
||||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return report.Resolved{}, err
|
|
||||||
}
|
|
||||||
if !metadata.ValidPeriod.IsValid() {
|
|
||||||
return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID)
|
|
||||||
}
|
|
||||||
return report.Resolved{
|
|
||||||
Definition: definition,
|
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
|
||||||
Timezone: location.String(),
|
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
192
internal/app/output.go
Normal file
192
internal/app/output.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
|
||||||
|
outputName, err := planned.Resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return validateOutputPath(filepath.Join(outputDir, outputName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error {
|
||||||
|
for index := range plannedReports {
|
||||||
|
outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plannedReports[index].OutputPath = outputPath
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) {
|
||||||
|
outputName, err := resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if override != "" {
|
||||||
|
return resolveOutputPath(workingDir, override, outputName)
|
||||||
|
}
|
||||||
|
outputDir, err := resolveOutputDir(workingDir, configuredDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return validateOutputPath(filepath.Join(outputDir, outputName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) {
|
||||||
|
directory := configuredDir
|
||||||
|
if override != "" {
|
||||||
|
directory = override
|
||||||
|
}
|
||||||
|
return resolveOutputDir(workingDir, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) {
|
||||||
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if override != "" {
|
||||||
|
return resolveComparisonDirectoryPath(workingDir, override)
|
||||||
|
}
|
||||||
|
outputDir, err := resolveOutputDir(workingDir, configuredDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name, err := comparison.DefaultDirectoryName(reportOutputName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(outputDir, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) {
|
||||||
|
if strings.TrimSpace(directory) == "" {
|
||||||
|
return "", fmt.Errorf("comparison output directory is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(directory) {
|
||||||
|
directory = filepath.Join(workingDir, directory)
|
||||||
|
}
|
||||||
|
return filepath.Clean(directory), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOutputDir(workingDir, override string) (string, error) {
|
||||||
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if override == "" {
|
||||||
|
return workingDir, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override) == "" {
|
||||||
|
return "", fmt.Errorf("output directory is required")
|
||||||
|
}
|
||||||
|
directory := override
|
||||||
|
if !filepath.IsAbs(directory) {
|
||||||
|
directory = filepath.Join(workingDir, directory)
|
||||||
|
}
|
||||||
|
directory = filepath.Clean(directory)
|
||||||
|
if err := preflightOutputDirectory(directory); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return directory, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preflightOutputDirectory(directory string) error {
|
||||||
|
info, err := os.Stat(directory)
|
||||||
|
if err == nil {
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("output directory %q is not a directory", directory)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("inspect output directory %q: %w", directory, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing directory is valid, but os.Stat also reports ErrNotExist for a
|
||||||
|
// dangling symlink. Walk to the first existing component so invalid links
|
||||||
|
// fail preflight instead of being discovered only during publication.
|
||||||
|
for component := directory; ; component = filepath.Dir(component) {
|
||||||
|
componentInfo, componentErr := os.Lstat(component)
|
||||||
|
if componentErr == nil {
|
||||||
|
if componentInfo.Mode()&os.ModeSymlink != 0 {
|
||||||
|
targetInfo, targetErr := os.Stat(component)
|
||||||
|
if targetErr != nil {
|
||||||
|
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr)
|
||||||
|
}
|
||||||
|
if !targetInfo.IsDir() {
|
||||||
|
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !componentInfo.IsDir() {
|
||||||
|
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(componentErr) {
|
||||||
|
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr)
|
||||||
|
}
|
||||||
|
if filepath.Dir(component) == component {
|
||||||
|
return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
|
||||||
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := override
|
||||||
|
if path == "" {
|
||||||
|
path = defaultName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("final output path is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(workingDir, path)
|
||||||
|
}
|
||||||
|
return validateOutputPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorkingDir(workingDir string) (string, error) {
|
||||||
|
if strings.TrimSpace(workingDir) == "" {
|
||||||
|
return "", fmt.Errorf("working directory is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(workingDir) {
|
||||||
|
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
|
||||||
|
}
|
||||||
|
return filepath.Clean(workingDir), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOutputPath(path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("final output path is required")
|
||||||
|
}
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return "", fmt.Errorf("final output path %q must be absolute", path)
|
||||||
|
}
|
||||||
|
if filepath.Dir(path) == path {
|
||||||
|
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
|
||||||
|
}
|
||||||
|
if err := fileutil.ValidateAtomicPath(path); err != nil {
|
||||||
|
return "", fmt.Errorf("validate final output path %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
59
internal/app/output_linux_test.go
Normal file
59
internal/app/output_linux_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsSpecialOutputBeforeWork(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T, path string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "named pipe",
|
||||||
|
setup: func(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socket",
|
||||||
|
setup: func(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
tt.setup(t, outputPath)
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: generationConfig(), Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
info, statErr := os.Lstat(outputPath)
|
||||||
|
if err == nil || result == nil || statErr != nil || info.Mode().IsRegular() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() result/error/output/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/app/output_test.go
Normal file
73
internal/app/output_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
configured := filepath.Join(workingDir, "configured")
|
||||||
|
blocked := filepath.Join(workingDir, "not-a-directory")
|
||||||
|
if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
override string
|
||||||
|
configuredDir string
|
||||||
|
reportOutputName string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "working directory default", reportOutputName: "today.md", want: filepath.Join(workingDir, "comparison-today")},
|
||||||
|
{name: "configured relative directory", configuredDir: "configured", reportOutputName: "tomorrow.md", want: filepath.Join(configured, "comparison-tomorrow")},
|
||||||
|
{name: "configured absolute directory", configuredDir: configured, reportOutputName: "hourly.md", want: filepath.Join(configured, "comparison-hourly")},
|
||||||
|
{name: "relative explicit directory", override: "exact", configuredDir: blocked, reportOutputName: "daily-2026-08-24.md", want: filepath.Join(workingDir, "exact")},
|
||||||
|
{name: "absolute explicit directory", override: filepath.Join(workingDir, "absolute"), reportOutputName: "today.md", want: filepath.Join(workingDir, "absolute")},
|
||||||
|
{name: "invalid report suffix", reportOutputName: "today.txt", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := resolveComparisonOutputDirectory(workingDir, test.override, test.configuredDir, test.reportOutputName)
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("resolveComparisonOutputDirectory() error = %v, want error %t", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if !test.wantErr && got != test.want {
|
||||||
|
t.Fatalf("resolveComparisonOutputDirectory() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
dangling := filepath.Join(workingDir, "dangling")
|
||||||
|
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
|
||||||
|
|
||||||
|
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
|
||||||
|
t.Run(filepath.Base(directory), func(t *testing.T) {
|
||||||
|
if _, err := resolveOutputDir(workingDir, directory); err == nil {
|
||||||
|
t.Fatalf("resolveOutputDir(%q) error = nil, want dangling symlink error", directory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
target := t.TempDir()
|
||||||
|
link := filepath.Join(workingDir, "linked")
|
||||||
|
testutil.RequireSymlink(t, target, link)
|
||||||
|
|
||||||
|
directory := filepath.Join(link, "reports")
|
||||||
|
got, err := resolveOutputDir(workingDir, directory)
|
||||||
|
if err != nil || got != directory {
|
||||||
|
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
internal/app/prepared_report.go
Normal file
148
internal/app/prepared_report.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// preparedReport contains the immutable deterministic inputs shared by prompt
|
||||||
|
// executions for one resolved report.
|
||||||
|
type preparedReport struct {
|
||||||
|
resolved report.Resolved
|
||||||
|
derived facts.DerivedFacts
|
||||||
|
moduleSnapshot module.Snapshot
|
||||||
|
identity briefing.PreparedIdentity
|
||||||
|
sourceWarnings []weatherdata.SourceWarning
|
||||||
|
dataPackage []byte
|
||||||
|
handler generatedtext.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type prepareReportRequest struct {
|
||||||
|
Config config.Config
|
||||||
|
Resolved report.Resolved
|
||||||
|
Collection collect.Result
|
||||||
|
handler generatedtext.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparationError struct {
|
||||||
|
operation string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *preparationError) Error() string {
|
||||||
|
return e.operation + ": " + e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *preparationError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
||||||
|
if req.Collection.Bundle == nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "prepare report", err: fmt.Errorf("collected weather bundle is required")}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, req.Collection.Bundle)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
|
||||||
|
}
|
||||||
|
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
|
||||||
|
identity := briefing.BuildPreparedIdentity(buildContext)
|
||||||
|
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved, Identity: identity}, reportFacts)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
|
||||||
|
}
|
||||||
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "build data package", err: err}
|
||||||
|
}
|
||||||
|
serializedDataPackage, err := promptinput.MarshalYAML(dataPackage)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
|
||||||
|
}
|
||||||
|
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
|
||||||
|
}
|
||||||
|
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
|
||||||
|
}
|
||||||
|
clonedIdentity, err := clonePreparedValue(identity)
|
||||||
|
if err != nil {
|
||||||
|
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
|
||||||
|
}
|
||||||
|
prepared := preparedReport{
|
||||||
|
resolved: cloneResolved(req.Resolved),
|
||||||
|
derived: clonedDerived,
|
||||||
|
moduleSnapshot: clonedSnapshot,
|
||||||
|
identity: clonedIdentity,
|
||||||
|
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
|
||||||
|
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||||
|
handler: req.handler,
|
||||||
|
}
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneResolved(value report.Resolved) report.Resolved {
|
||||||
|
cloned := value
|
||||||
|
cloned.Definition.DistributorPathTemplates = append([]string(nil), value.Definition.DistributorPathTemplates...)
|
||||||
|
cloned.Definition.Modules = make([]module.ConfigItem, len(value.Definition.Modules))
|
||||||
|
for i, item := range value.Definition.Modules {
|
||||||
|
cloned.Definition.Modules[i] = item
|
||||||
|
switch options := item.Options.(type) {
|
||||||
|
case module.AreaForecastDiscussionOptions:
|
||||||
|
options.Sections = append([]string(nil), options.Sections...)
|
||||||
|
cloned.Definition.Modules[i].Options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p preparedReport) dataPackageCopy() []byte {
|
||||||
|
return append([]byte(nil), p.dataPackage...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
|
||||||
|
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
|
||||||
|
identity, err := clonePreparedValue(p.identity)
|
||||||
|
if err != nil {
|
||||||
|
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||||
|
}
|
||||||
|
snapshot, err := clonePreparedValue(p.moduleSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||||
|
}
|
||||||
|
derived, err := clonePreparedValue(p.derived)
|
||||||
|
if err != nil {
|
||||||
|
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||||
|
}
|
||||||
|
return identity, snapshot, derived, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePreparedValue[T any](value T) (T, error) {
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("marshal immutable prepared value: %w", err)
|
||||||
|
}
|
||||||
|
var cloned T
|
||||||
|
if err := json.Unmarshal(encoded, &cloned); err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("unmarshal immutable prepared value: %w", err)
|
||||||
|
}
|
||||||
|
return cloned, nil
|
||||||
|
}
|
||||||
121
internal/app/prepared_report_test.go
Normal file
121
internal/app/prepared_report_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
||||||
|
cfg := generationConfig()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)}
|
||||||
|
prepared, err := prepareReport(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareReport() error = %v", err)
|
||||||
|
}
|
||||||
|
repeated, err := prepareReport(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second prepareReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
|
||||||
|
t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
|
||||||
|
originalIdentity := prepared.identity
|
||||||
|
originalDerived := prepared.derived
|
||||||
|
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
|
||||||
|
identity, snapshot, derived, err := prepared.renderInputs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderInputs() error = %v", err)
|
||||||
|
}
|
||||||
|
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
|
||||||
|
snapshot.Outputs = nil
|
||||||
|
derived.PrecipTiming.ThunderMentioned = false
|
||||||
|
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
|
||||||
|
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
|
||||||
|
if len(bundle.Sources) > 0 {
|
||||||
|
if bundle.Sources[0].Query == nil {
|
||||||
|
bundle.Sources[0].Query = map[string]string{}
|
||||||
|
}
|
||||||
|
bundle.Sources[0].Query["mutated"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.derived, originalDerived) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
|
||||||
|
t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
|
||||||
|
}
|
||||||
|
if len(prepared.moduleSnapshot.Outputs) == 0 {
|
||||||
|
t.Fatal("prepared report values retain consumer mutation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
|
||||||
|
cfg := generationConfig()
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareReport() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := prepared.identity
|
||||||
|
renderIdentity, _, _, err := prepared.renderInputs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderInputs() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(renderIdentity, identity) {
|
||||||
|
t.Fatalf("render identity = %#v, want %#v", renderIdentity, identity)
|
||||||
|
}
|
||||||
|
prompt := promptMetadata(identity)
|
||||||
|
if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) {
|
||||||
|
t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err)
|
||||||
|
}
|
||||||
|
if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) {
|
||||||
|
t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity)
|
||||||
|
}
|
||||||
|
if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) {
|
||||||
|
t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings)
|
||||||
|
}
|
||||||
|
for index, warning := range identity.SourceWarnings {
|
||||||
|
summary := moduleMetadata.SourceWarnings[index]
|
||||||
|
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
|
||||||
|
t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparedHandler(t *testing.T, resolved report.Resolved) generatedtext.Handler {
|
||||||
|
t.Helper()
|
||||||
|
handler, err := generatedtext.LookupDefinition(resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition() error = %v", err)
|
||||||
|
}
|
||||||
|
return handler
|
||||||
|
}
|
||||||
200
internal/app/profile_execution.go
Normal file
200
internal/app/profile_execution.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type profileExecutionRequest struct {
|
||||||
|
Prepared preparedReport
|
||||||
|
Prompt PromptInspectionResult
|
||||||
|
Profile promptexec.ProfileInspection
|
||||||
|
Executor promptexec.Executor
|
||||||
|
DebugWriter *promptdebug.PromptDebugWriter
|
||||||
|
DebugRef *promptdebug.PromptDebugRef
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileExecutionOutcome struct {
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
ValidationStatus promptexec.ValidationStatus
|
||||||
|
LLMDebugPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileExecutionError struct {
|
||||||
|
operation string
|
||||||
|
err error
|
||||||
|
callbackFailure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *profileExecutionError) Error() string {
|
||||||
|
return e.operation + ": " + e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *profileExecutionError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (profileExecutionOutcome, []byte, error) {
|
||||||
|
outcome := profileExecutionOutcome{
|
||||||
|
ProfileID: req.Profile.ProfileID,
|
||||||
|
BackendID: req.Profile.BackendID,
|
||||||
|
ModelName: req.Profile.ModelName,
|
||||||
|
}
|
||||||
|
if req.Executor == nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)}
|
||||||
|
}
|
||||||
|
if err := validatePreparedExecutionRequest(req); err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackFailed := false
|
||||||
|
preparationCount := 0
|
||||||
|
var preparation promptexec.Preparation
|
||||||
|
preparationCallback := func(value promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
preparationCount++
|
||||||
|
if preparationCount != 1 {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
if err := validatePreparationProvenance(req, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preparation = clonePreparation(value)
|
||||||
|
if req.DebugWriter == nil || !req.DebugWriter.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if req.DebugRef == nil {
|
||||||
|
callbackFailed = true
|
||||||
|
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
|
||||||
|
}
|
||||||
|
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, value, debug)
|
||||||
|
if err != nil {
|
||||||
|
callbackFailed = true
|
||||||
|
return promptDebugWriteError(err)
|
||||||
|
}
|
||||||
|
outcome.LLMDebugPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
captureDebug := req.DebugWriter != nil && req.DebugWriter.Enabled()
|
||||||
|
execution, err := req.Executor.Execute(ctx, promptexec.ExecuteRequest{
|
||||||
|
PromptID: req.Prompt.PromptID,
|
||||||
|
PromptVersion: req.Prompt.PromptVersion,
|
||||||
|
ProfileID: req.Profile.ProfileID,
|
||||||
|
DataPackage: req.Prepared.dataPackageCopy(),
|
||||||
|
CaptureDebug: captureDebug,
|
||||||
|
}, preparationCallback)
|
||||||
|
if err != nil {
|
||||||
|
if callbackFailed {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: err, callbackFailure: true}
|
||||||
|
}
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: classifiedPromptError("prompt execution failed", err)}
|
||||||
|
}
|
||||||
|
if execution == nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)}
|
||||||
|
}
|
||||||
|
if preparationCount != 1 {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: promptProvenanceError()}
|
||||||
|
}
|
||||||
|
if err := validateExecutionProvenance(req, preparation, *execution); err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
|
||||||
|
}
|
||||||
|
outcome.ValidationStatus = execution.Validation.Status
|
||||||
|
if err := generatedtext.ValidateRawOutput(execution.RawOutput); err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||||
|
}
|
||||||
|
if req.DebugWriter != nil && req.DebugWriter.Enabled() {
|
||||||
|
if req.DebugRef == nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))}
|
||||||
|
}
|
||||||
|
path, err := req.DebugWriter.WriteExecution(*req.DebugRef, *execution)
|
||||||
|
if err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(err)}
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
outcome.LLMDebugPath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)}
|
||||||
|
}
|
||||||
|
if execution.Validation.Status == promptexec.ValidationFailed {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedText, err := req.Prepared.handler.Validate(execution.RawOutput)
|
||||||
|
if err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||||
|
}
|
||||||
|
identity, snapshot, derived, err := req.Prepared.renderInputs()
|
||||||
|
if err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
|
||||||
|
}
|
||||||
|
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
|
||||||
|
if err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
|
||||||
|
}
|
||||||
|
rendered, err := req.Prepared.handler.Render(renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return outcome, nil, &profileExecutionError{operation: "render template", err: err}
|
||||||
|
}
|
||||||
|
return outcome, rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePreparedExecutionRequest(req profileExecutionRequest) error {
|
||||||
|
definition := req.Prepared.resolved.Definition
|
||||||
|
if definition.PromptID != req.Prompt.PromptID || definition.PromptVersion != req.Prompt.PromptVersion ||
|
||||||
|
definition.GeneratedTextSchemaID != req.Prepared.handler.SchemaID() {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
if req.Prompt.ProfileID != "" && (req.Prompt.ProfileID != req.Profile.ProfileID || req.Prompt.BackendID != req.Profile.BackendID || req.Prompt.ModelName != req.Profile.ModelName) {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
if req.Prompt.PromptHash == "" || req.Profile.ProfileID == "" || req.Profile.BackendID == "" || req.Profile.ModelName == "" {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePreparationProvenance(req profileExecutionRequest, preparation promptexec.Preparation) error {
|
||||||
|
definition := req.Prepared.resolved.Definition
|
||||||
|
if preparation.PromptID != req.Prompt.PromptID || preparation.PromptVersion != req.Prompt.PromptVersion || preparation.PromptHash != req.Prompt.PromptHash ||
|
||||||
|
preparation.ProfileID != req.Profile.ProfileID || preparation.BackendID != req.Profile.BackendID || preparation.ModelName != req.Profile.ModelName ||
|
||||||
|
!validPromptOutput(definition, preparation.Output) {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExecutionProvenance(req profileExecutionRequest, preparation promptexec.Preparation, execution promptexec.Execution) error {
|
||||||
|
definition := req.Prepared.resolved.Definition
|
||||||
|
if execution.PromptID != preparation.PromptID || execution.PromptVersion != preparation.PromptVersion || execution.PromptHash != preparation.PromptHash ||
|
||||||
|
execution.RenderedPromptHash != preparation.RenderedPromptHash || !reflect.DeepEqual(execution.InputHashes, preparation.InputHashes) ||
|
||||||
|
execution.ProfileID != preparation.ProfileID || execution.BackendID != preparation.BackendID || execution.ModelName != preparation.ModelName ||
|
||||||
|
execution.Validation.Mode != "json_schema" || execution.Validation.SchemaPath != definition.GeneratedTextSchemaID+".generated_text.schema.json" {
|
||||||
|
return promptProvenanceError()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptProvenanceError() error {
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt execution provenance is inconsistent", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePreparation(value promptexec.Preparation) promptexec.Preparation {
|
||||||
|
if value.InputHashes != nil {
|
||||||
|
inputHashes := make(map[string]string, len(value.InputHashes))
|
||||||
|
for name, hash := range value.InputHashes {
|
||||||
|
inputHashes[name] = hash
|
||||||
|
}
|
||||||
|
value.InputHashes = inputHashes
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
176
internal/app/profile_execution_test.go
Normal file
176
internal/app/profile_execution_test.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
||||||
|
prepared, inspection := preparedDailyProfile(t)
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "report.md")
|
||||||
|
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||||
|
Prepared: prepared, Prompt: inspection,
|
||||||
|
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||||
|
Executor: executor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executePreparedProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(rendered) == 0 || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ProfileID != inspection.ProfileID || executor.executeCalls != 1 {
|
||||||
|
t.Fatalf("outcome/rendered/execution calls = %#v/%q/%d", outcome, rendered, executor.executeCalls)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("execution unexpectedly published %q: %v", outputPath, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
||||||
|
prepared, inspection := preparedDailyProfile(t)
|
||||||
|
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||||
|
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||||
|
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||||
|
Prepared: prepared, Prompt: inspection,
|
||||||
|
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||||
|
Executor: executor, DebugWriter: debugWriter,
|
||||||
|
DebugRef: &promptdebug.PromptDebugRef{ReportID: inspectionResolved(t).Definition.ID, ValidDate: "2026-05-29", RunID: "invalid/path"},
|
||||||
|
})
|
||||||
|
var executionErr *profileExecutionError
|
||||||
|
if err == nil || !errors.As(err, &executionErr) || !executionErr.callbackFailure || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 || executor.executeCalls != 0 || outcome.LLMDebugPath != "" {
|
||||||
|
t.Fatalf("outcome/rendered/error/execution calls = %#v/%q/%v/%d", outcome, rendered, err, executor.executeCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePreparedProfileBoundsOversizedExecutorOutput(t *testing.T) {
|
||||||
|
prepared, inspection := preparedDailyProfile(t)
|
||||||
|
marker := "provider-controlled-marker"
|
||||||
|
executor := &generationExecutor{rawOutput: []byte(strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1) + marker)}
|
||||||
|
_, _, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||||
|
Prepared: prepared, Prompt: inspection,
|
||||||
|
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||||
|
Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "65536-byte limit") {
|
||||||
|
t.Fatalf("executePreparedProfile() error = %v, want bounded raw size error", err)
|
||||||
|
}
|
||||||
|
if len(err.Error()) > 160 || strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("ordinary error leaked provider content: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePreparedProfileRejectsInconsistentProvenance(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*preparedReport, *PromptInspectionResult, *promptexec.ProfileInspection, *generationExecutor)
|
||||||
|
invoked bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prepared definition", mutate: func(prepared *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, _ *generationExecutor) {
|
||||||
|
prepared.resolved.Definition.PromptVersion = "different-version"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.skipPreparation = true
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.preparationCalls = 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "callback prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.prepare = func(value *promptexec.Preparation) { value.PromptHash = "different-hash" }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "callback output schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.prepare = func(value *promptexec.Preparation) { value.Output.SchemaPath = "other.generated_text.schema.json" }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed profile", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.complete = func(value *promptexec.Execution) { value.ProfileID = "different-profile" }
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed rendered prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.complete = func(value *promptexec.Execution) { value.RenderedPromptHash = "different-rendered-hash" }
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed input hashes", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.prepare = func(value *promptexec.Preparation) {
|
||||||
|
value.InputHashes = map[string]string{"data_package": "prepared-hash"}
|
||||||
|
}
|
||||||
|
executor.complete = func(value *promptexec.Execution) {
|
||||||
|
value.InputHashes = map[string]string{"data_package": "completed-hash"}
|
||||||
|
}
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed validation mode", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.complete = func(value *promptexec.Execution) { value.Validation.Mode = "other" }
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed validation schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||||
|
executor.complete = func(value *promptexec.Execution) { value.Validation.SchemaPath = "other.generated_text.schema.json" }
|
||||||
|
}, invoked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
prepared, inspection := preparedDailyProfile(t)
|
||||||
|
profile := promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName}
|
||||||
|
executor := &generationExecutor{}
|
||||||
|
tt.mutate(&prepared, &inspection, &profile, executor)
|
||||||
|
|
||||||
|
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{Prepared: prepared, Prompt: inspection, Profile: profile, Executor: executor})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 {
|
||||||
|
t.Fatalf("outcome/rendered/error = %#v/%q/%v", outcome, rendered, err)
|
||||||
|
}
|
||||||
|
if outcome.ProfileID != profile.ProfileID || outcome.BackendID != profile.BackendID || outcome.ModelName != profile.ModelName || outcome.ValidationStatus != "" {
|
||||||
|
t.Fatalf("outcome retained unverified provenance: %#v", outcome)
|
||||||
|
}
|
||||||
|
if (executor.executeCalls == 1) != tt.invoked {
|
||||||
|
t.Fatalf("executor calls = %d, want invoked=%t", executor.executeCalls, tt.invoked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := generationConfig()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily,
|
||||||
|
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareReport() error = %v", err)
|
||||||
|
}
|
||||||
|
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: generationPromptHash, ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
|
||||||
|
}
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,14 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"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/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type promptReportRequest struct {
|
type promptReportRequest struct {
|
||||||
@@ -21,392 +17,110 @@ type promptReportRequest struct {
|
|||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Collection collect.Result
|
Collection collect.Result
|
||||||
Inspection PromptInspectionResult
|
Inspection PromptInspectionResult
|
||||||
DebugWriter *state.PromptDebugWriter
|
DebugWriter *promptdebug.PromptDebugWriter
|
||||||
|
Result *ReportResult
|
||||||
noNotify bool
|
noNotify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
|
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 {
|
if req.Collection.Bundle == nil {
|
||||||
return nil, fmt.Errorf("collected weather bundle is required")
|
return nil, fmt.Errorf("collected weather bundle is required")
|
||||||
}
|
}
|
||||||
store := req.Store
|
result := req.Result
|
||||||
var err error
|
if result == nil {
|
||||||
if store == nil {
|
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
|
||||||
store, err = defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return &promptReportWorkflow{ctx: ctx, req: req, store: store}, nil
|
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
|
||||||
}
|
|
||||||
|
|
||||||
func (w *promptReportWorkflow) buildInputs() error {
|
|
||||||
paths, err := w.store.Paths(w.req.Resolved)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return result, generatedPreparationError(req.Resolved, result.RunID, err)
|
||||||
}
|
}
|
||||||
w.result = &ReportResult{}
|
result.SourceWarnings = prepared.sourceWarningsCopy()
|
||||||
priorSnapshot, err := w.store.FindPriorSnapshot(w.ctx, w.req.Resolved)
|
debugRef := promptdebug.PromptDebugRef{ReportID: result.ReportID, ValidDate: prepared.resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: result.RunID}
|
||||||
if err != nil {
|
outcome, rendered, err := executePreparedProfile(ctx, profileExecutionRequest{
|
||||||
return err
|
Prepared: prepared,
|
||||||
}
|
Prompt: req.Inspection,
|
||||||
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
|
Profile: promptexec.ProfileInspection{
|
||||||
if err != nil {
|
ProfileID: req.Inspection.ProfileID,
|
||||||
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build report facts", err)
|
BackendID: req.Inspection.BackendID,
|
||||||
}
|
ModelName: req.Inspection.ModelName,
|
||||||
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
|
},
|
||||||
if err != nil {
|
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
|
||||||
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
|
result.ProfileID, result.BackendID, result.ModelName = outcome.ProfileID, outcome.BackendID, outcome.ModelName
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
result.ValidationStatus = outcome.ValidationStatus
|
||||||
Metadata: promptMetadata(w.metadata), Modules: w.moduleSnapshot, RecentChanges: recent,
|
result.LLMDebugPath = outcome.LLMDebugPath
|
||||||
|
if err != nil {
|
||||||
|
return result, generatedProfileExecutionError(req.Resolved, result.RunID, err)
|
||||||
|
}
|
||||||
|
return publishPromptReport(ctx, promptPublicationRequest{
|
||||||
|
GenerateRequest: req.GenerateRequest,
|
||||||
|
Resolved: req.Resolved,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
Result: result,
|
||||||
|
Markdown: rendered,
|
||||||
|
suppressNotification: req.noNotify,
|
||||||
})
|
})
|
||||||
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) {
|
func initialReportResult(req GenerateRequest, resolved report.Resolved, inspection PromptInspectionResult) *ReportResult {
|
||||||
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{
|
metadata := resolved.Metadata()
|
||||||
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
return &ReportResult{
|
||||||
ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackageBytes,
|
ReportID: resolved.Definition.ID, ReportName: resolved.Definition.Name,
|
||||||
DataPackagePath: w.result.DataPackagePath, CaptureDebug: w.req.DebugWriter.Enabled(),
|
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
|
||||||
}, w.persistPreparation)
|
RunID: metadata.RunID, GeneratedAt: metadata.GeneratedAt, Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *promptReportWorkflow) persistPreparation(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
type promptPublicationRequest struct {
|
||||||
artifact := state.PromptPreparationArtifact{
|
GenerateRequest
|
||||||
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationSucceeded,
|
Resolved report.Resolved
|
||||||
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
OutputPath string
|
||||||
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
Result *ReportResult
|
||||||
DataPackagePath: w.result.DataPackagePath, Preparation: &preparation,
|
Markdown []byte
|
||||||
StartedAt: preparation.StartedAt, EndedAt: preparation.EndedAt, Duration: preparation.Duration,
|
suppressNotification bool
|
||||||
}
|
|
||||||
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 {
|
func publishPromptReport(ctx context.Context, req promptPublicationRequest) (*ReportResult, error) {
|
||||||
if w.callbackFailed {
|
if err := publicationContextError(ctx); err != nil {
|
||||||
return executeErr
|
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
|
||||||
}
|
}
|
||||||
executeErr = classifiedPromptError("prompt execution failed", executeErr)
|
if err := fileutil.WriteFileAtomicContext(ctx, req.OutputPath, req.Markdown); err != nil {
|
||||||
if !w.prepared {
|
if contextErr := publicationContextError(ctx); contextErr != nil {
|
||||||
if err := w.persistPreparationFailure(executeErr); err != nil {
|
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", contextErr)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return w.reportError("prepare prompt", executeErr)
|
return req.Result, err
|
||||||
}
|
}
|
||||||
if promptexec.CategoryOf(executeErr) != "" {
|
req.Result.OutputPath = req.OutputPath
|
||||||
if err := w.persistOperationalExecutionFailure(executeErr); err != nil {
|
if req.suppressNotification {
|
||||||
return err
|
return req.Result, nil
|
||||||
|
}
|
||||||
|
notification, err := notifyReport(ctx, req.Config, req.Resolved, req.Result.OutputPath, req.Result.RunID, req.Result.GeneratedAt, req.Notifier)
|
||||||
|
req.Result.Notification = notification
|
||||||
|
if err != nil {
|
||||||
|
return req.Result, err
|
||||||
|
}
|
||||||
|
return req.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedPreparationError(resolved report.Resolved, runID string, err error) error {
|
||||||
|
var preparation *preparationError
|
||||||
|
if errors.As(err, &preparation) {
|
||||||
|
return generatedReportError(resolved, runID, preparation.operation, preparation.err)
|
||||||
|
}
|
||||||
|
return generatedReportError(resolved, runID, "prepare report", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedProfileExecutionError(resolved report.Resolved, runID string, err error) error {
|
||||||
|
var execution *profileExecutionError
|
||||||
|
if errors.As(err, &execution) {
|
||||||
|
if execution.callbackFailure {
|
||||||
|
return execution.err
|
||||||
}
|
}
|
||||||
|
return generatedReportError(resolved, runID, execution.operation, execution.err)
|
||||||
}
|
}
|
||||||
return w.reportError("execute prompt", executeErr)
|
return generatedReportError(resolved, runID, "execute prompt", err)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func classifiedPromptError(operation string, err error) error {
|
||||||
@@ -416,8 +130,16 @@ func classifiedPromptError(operation string, err error) error {
|
|||||||
return promptexec.NewError(promptexec.Generation, operation, err)
|
return promptexec.NewError(promptexec.Generation, operation, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publicationContextError(ctx context.Context) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func promptDebugWriteError(err error) error {
|
func promptDebugWriteError(err error) error {
|
||||||
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptr[T any](value T) *T { return &value }
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
@@ -28,6 +30,7 @@ type PromptInspectionResult struct {
|
|||||||
ProfileID string
|
ProfileID string
|
||||||
BackendID string
|
BackendID string
|
||||||
ModelName string
|
ModelName string
|
||||||
|
handler generatedtext.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||||
@@ -39,6 +42,33 @@ type PromptExecutionsInspectionRequest struct {
|
|||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComparisonInspectionRequest contains the explicit profile selection for one
|
||||||
|
// resolved prompt comparison. It intentionally has no configured profile field.
|
||||||
|
type ComparisonInspectionRequest struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
ProfileIDs []string
|
||||||
|
Executor promptexec.Executor
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparisonInspectionResult contains the safe, shared prompt identity and
|
||||||
|
// ordered effective profile identities for a comparison.
|
||||||
|
type ComparisonInspectionResult struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
Profiles []ComparisonProfileInspection
|
||||||
|
handler generatedtext.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparisonProfileInspection contains one requested profile's safe effective
|
||||||
|
// execution identity.
|
||||||
|
type ComparisonProfileInspection struct {
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
}
|
||||||
|
|
||||||
// InspectPromptExecution validates the exact prompt and profile needed for a
|
// InspectPromptExecution validates the exact prompt and profile needed for a
|
||||||
// report before collection, execution, or durable writes begin.
|
// report before collection, execution, or durable writes begin.
|
||||||
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
||||||
@@ -64,21 +94,13 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
|||||||
profiles := map[string]promptexec.ProfileInspection{}
|
profiles := map[string]promptexec.ProfileInspection{}
|
||||||
for _, resolved := range req.Resolved {
|
for _, resolved := range req.Resolved {
|
||||||
definition := resolved.Definition
|
definition := resolved.Definition
|
||||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
handler, err := generatedtext.LookupDefinition(definition)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, promptInspectionError("prompt inspection failed", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
|
||||||
}
|
}
|
||||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
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
|
profileID := req.Promptkit.Profile
|
||||||
if profileID == "" {
|
if profileID == "" {
|
||||||
@@ -98,11 +120,76 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
|||||||
results[definition.ID] = PromptInspectionResult{
|
results[definition.ID] = PromptInspectionResult{
|
||||||
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||||
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
|
handler: handler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectComparisonExecution validates one exact prompt and every explicitly
|
||||||
|
// requested profile before collection or model execution. Profiles are
|
||||||
|
// inspected sequentially in request order. If a profile fails, the returned
|
||||||
|
// partial result retains the prompt identity and successfully inspected prefix.
|
||||||
|
func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionRequest) (ComparisonInspectionResult, error) {
|
||||||
|
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
|
||||||
|
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidRequest, "comparison profile selection is invalid", err)
|
||||||
|
}
|
||||||
|
if req.Executor == nil {
|
||||||
|
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return ComparisonInspectionResult{}, comparisonInspectionError("comparison generated-text catalog inspection failed", promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err))
|
||||||
|
}
|
||||||
|
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
|
||||||
|
}
|
||||||
|
result := ComparisonInspectionResult{
|
||||||
|
PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion,
|
||||||
|
PromptHash: inspection.PromptHash,
|
||||||
|
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
|
||||||
|
handler: handler,
|
||||||
|
}
|
||||||
|
for _, profileID := range req.ProfileIDs {
|
||||||
|
profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||||
|
if err != nil {
|
||||||
|
return result, comparisonInspectionError("comparison profile inspection failed", err)
|
||||||
|
}
|
||||||
|
result.Profiles = append(result.Profiles, ComparisonProfileInspection{
|
||||||
|
ProfileID: profile.ProfileID,
|
||||||
|
BackendID: profile.BackendID,
|
||||||
|
ModelName: profile.ModelName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectPromptContract(ctx context.Context, executor promptexec.Executor, definition report.Definition) (promptexec.PromptInspection, error) {
|
||||||
|
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||||
|
}
|
||||||
|
inspection, err := executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||||
|
if err != nil {
|
||||||
|
return promptexec.PromptInspection{}, promptInspectionError("prompt inspection failed", err)
|
||||||
|
}
|
||||||
|
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(inspection.PromptHash) == "" {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return a prompt hash", nil)
|
||||||
|
}
|
||||||
|
if !validPromptInput(inspection.Inputs) {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||||
|
}
|
||||||
|
if !validPromptOutput(definition, inspection.Output) {
|
||||||
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||||
|
}
|
||||||
|
return inspection, nil
|
||||||
|
}
|
||||||
|
|
||||||
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
|
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
|
||||||
profile, err := executor.InspectProfile(ctx, profileID)
|
profile, err := executor.InspectProfile(ctx, profileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,6 +210,9 @@ func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, pro
|
|||||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(profile.BackendID) == "" || strings.TrimSpace(profile.ModelName) == "" {
|
||||||
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return a complete execution identity", nil)
|
||||||
|
}
|
||||||
return profile, nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,3 +230,11 @@ func promptInspectionError(operation string, err error) error {
|
|||||||
}
|
}
|
||||||
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
|
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func comparisonInspectionError(operation string, err error) error {
|
||||||
|
category := promptexec.CategoryOf(err)
|
||||||
|
if category == "" {
|
||||||
|
category = promptexec.InvalidConfiguration
|
||||||
|
}
|
||||||
|
return promptexec.NewError(category, operation, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -70,6 +71,21 @@ func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.
|
|||||||
}(),
|
}(),
|
||||||
wantCategory: promptexec.InvalidConfiguration,
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "missing prompt hash",
|
||||||
|
prompt: func() promptexec.PromptInspection {
|
||||||
|
value := basePrompt
|
||||||
|
value.PromptHash = ""
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing profile backend",
|
||||||
|
prompt: basePrompt,
|
||||||
|
profile: promptexec.ProfileInspection{ProfileID: "default-profile", ModelName: "model"},
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "direct key",
|
name: "direct key",
|
||||||
prompt: basePrompt,
|
prompt: basePrompt,
|
||||||
@@ -109,9 +125,7 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
|||||||
|
|
||||||
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||||
first := inspectionResolved(t)
|
first := inspectionResolved(t)
|
||||||
second := first
|
second := inspectionResolvedFor(t, report.Today)
|
||||||
second.Definition.ID = report.Today
|
|
||||||
second.Definition.PromptID = "weather.today"
|
|
||||||
executor := &inspectionExecutor{
|
executor := &inspectionExecutor{
|
||||||
prompt: validPromptInspection(first.Definition),
|
prompt: validPromptInspection(first.Definition),
|
||||||
profiles: map[string]promptexec.ProfileInspection{
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
@@ -131,6 +145,159 @@ func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPromptInspectionRejectsIncompatibleGeneratedTextCatalogBeforeExecutorWork(t *testing.T) {
|
||||||
|
base := inspectionResolved(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resolved report.Resolved
|
||||||
|
inspect func(context.Context, report.Resolved, *inspectionExecutor) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single report unknown template",
|
||||||
|
resolved: func() report.Resolved {
|
||||||
|
resolved := base
|
||||||
|
resolved.Definition.TemplateID = "unknown"
|
||||||
|
return resolved
|
||||||
|
}(),
|
||||||
|
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||||
|
_, err := InspectPromptExecution(ctx, PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "batch known pair for another report",
|
||||||
|
resolved: func() report.Resolved {
|
||||||
|
resolved := base
|
||||||
|
resolved.Definition.GeneratedTextSchemaID = "today"
|
||||||
|
resolved.Definition.TemplateID = "today"
|
||||||
|
return resolved
|
||||||
|
}(),
|
||||||
|
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||||
|
_, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{Resolved: []report.Resolved{resolved}, Executor: executor})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comparison known pair for another report",
|
||||||
|
resolved: func() report.Resolved {
|
||||||
|
resolved := base
|
||||||
|
resolved.Definition.GeneratedTextSchemaID = "today"
|
||||||
|
resolved.Definition.TemplateID = "today"
|
||||||
|
return resolved
|
||||||
|
}(),
|
||||||
|
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||||
|
_, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
executor := &inspectionExecutor{}
|
||||||
|
err := test.inspect(context.Background(), test.resolved, executor)
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("inspection error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||||
|
t.Fatalf("incompatible catalog performed executor work: prompts %#v profiles %#v executions %d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(resolved.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||||
|
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
profileIDs := []string{"weather-light", "weather-deep"}
|
||||||
|
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||||
|
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectComparisonExecution() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash != "prompt-hash" {
|
||||||
|
t.Fatalf("prompt result = %#v", result)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(executor.profileRequests, profileIDs) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
|
||||||
|
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||||
|
}
|
||||||
|
wantProfiles := []ComparisonProfileInspection{
|
||||||
|
{ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||||
|
{ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Profiles, wantProfiles) {
|
||||||
|
t.Fatalf("profiles = %#v, want %#v", result.Profiles, wantProfiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectComparisonExecutionRejectsInvalidProfilesBeforeInspection(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
for _, profileIDs := range [][]string{
|
||||||
|
{"weather-light"},
|
||||||
|
{"weather-light", " \t"},
|
||||||
|
{"weather-light", "weather-light"},
|
||||||
|
} {
|
||||||
|
t.Run(strings.Join(profileIDs, ","), func(t *testing.T) {
|
||||||
|
executor := &inspectionExecutor{prompt: validPromptInspection(resolved.Definition)}
|
||||||
|
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||||
|
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidRequest {
|
||||||
|
t.Fatalf("error/category = %v/%q, want invalid request", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||||
|
t.Fatalf("invalid profile selection performed prompt/profile/execution work: %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectComparisonExecutionStopsAtFirstProfileFailure(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(resolved.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||||
|
"missing-key": {ProfileID: "missing-key", APIKeyEnv: "PROMPT_API_KEY"},
|
||||||
|
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||||
|
Resolved: resolved, ProfileIDs: []string{"weather-light", "missing-key", "weather-deep"}, Executor: executor,
|
||||||
|
LookupEnv: func(string) (string, bool) { return "", false },
|
||||||
|
})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.MissingCredential {
|
||||||
|
t.Fatalf("error/category = %v/%q, want missing credential", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(executor.profileRequests, []string{"weather-light", "missing-key"}) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
|
||||||
|
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||||
|
}
|
||||||
|
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash == "" || len(result.Profiles) != 1 || result.Profiles[0].ProfileID != "weather-light" {
|
||||||
|
t.Fatalf("partial inspection result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectComparisonExecutionStopsBeforeProfileInspectionWhenPromptFails(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{promptErr: promptexec.NewError(promptexec.PromptNotFound, "prompt is unavailable", nil)}
|
||||||
|
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||||
|
Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.PromptNotFound || !strings.Contains(err.Error(), "comparison prompt") {
|
||||||
|
t.Fatalf("error/category = %v/%q, want prompt-context prompt not found", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != 1 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||||
|
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type inspectionPromptRequest struct {
|
type inspectionPromptRequest struct {
|
||||||
id string
|
id string
|
||||||
version string
|
version string
|
||||||
@@ -143,6 +310,7 @@ type inspectionExecutor struct {
|
|||||||
promptErr error
|
promptErr error
|
||||||
promptRequests []inspectionPromptRequest
|
promptRequests []inspectionPromptRequest
|
||||||
profileRequests []string
|
profileRequests []string
|
||||||
|
executeRequests int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
||||||
@@ -166,16 +334,21 @@ func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
e.executeRequests++
|
||||||
return nil, errors.New("unexpected execution")
|
return nil, errors.New("unexpected execution")
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspectionResolved(t *testing.T) report.Resolved {
|
func inspectionResolved(t *testing.T) report.Resolved {
|
||||||
|
return inspectionResolvedFor(t, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
|
||||||
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
if id == report.Daily {
|
||||||
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
|
||||||
Location: time.UTC,
|
}
|
||||||
})
|
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -189,3 +362,13 @@ func validPromptInspection(definition report.Definition) promptexec.PromptInspec
|
|||||||
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logicalPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||||
|
inspection := validPromptInspection(definition)
|
||||||
|
if definition.ID == report.Hourly {
|
||||||
|
inspection.DefaultProfileID = "weather-light"
|
||||||
|
} else {
|
||||||
|
inspection.DefaultProfileID = "weather-balanced"
|
||||||
|
}
|
||||||
|
return inspection
|
||||||
|
}
|
||||||
|
|||||||
74
internal/app/prompt_profile_integration_test.go
Normal file
74
internal/app/prompt_profile_integration_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing.T) {
|
||||||
|
lookupEnv := func(string) (string, bool) { return "test-key", true }
|
||||||
|
inspect := func(t *testing.T, adapter *promptkitadapter.Adapter, id report.ID, profile string, wantID string, wantBackend string, wantModel string) {
|
||||||
|
t.Helper()
|
||||||
|
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
|
||||||
|
Resolved: resolvedPromptProfile(t, id),
|
||||||
|
Executor: adapter,
|
||||||
|
Promptkit: config.PromptkitConfig{Profile: profile},
|
||||||
|
LookupEnv: lookupEnv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecution() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.ProfileID != wantID || result.BackendID != wantBackend || result.ModelName != wantModel {
|
||||||
|
t.Fatalf("inspection = %#v, want profile/backend/model %q/%q/%q", result, wantID, wantBackend, wantModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embedded, err := promptkitadapter.New(promptkitadapter.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(embedded) error = %v", err)
|
||||||
|
}
|
||||||
|
inspect(t, embedded, report.Hourly, "", "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||||
|
inspect(t, embedded, report.Daily, "", "weather-balanced", "openrouter", "~google/gemini-flash-latest")
|
||||||
|
inspect(t, embedded, report.Daily, "weather-deep", "weather-deep", "openrouter", "~anthropic/claude-sonnet-latest")
|
||||||
|
|
||||||
|
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
|
||||||
|
endpoint: https://local.example/v1
|
||||||
|
backend: openrouter
|
||||||
|
model: local-weather
|
||||||
|
`)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(override) error = %v", err)
|
||||||
|
}
|
||||||
|
inspect(t, override, report.Hourly, "", "weather-light", "openrouter", "local-weather")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
request := report.ResolveRequest{Now: now, Location: time.UTC}
|
||||||
|
if id == report.Daily {
|
||||||
|
request.Date = now
|
||||||
|
}
|
||||||
|
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve(%q) error = %v", id, err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProfileFile(t *testing.T, profile string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "profile.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -1,659 +0,0 @@
|
|||||||
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"}`
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,50 @@
|
|||||||
{
|
{
|
||||||
|
"provenance": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"url": "https://www.spc.noaa.gov/about/outlooks/",
|
||||||
|
"updated_on": "2026-03-03",
|
||||||
|
"applies_to": "categorical outlook descriptions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.spc.noaa.gov/exper/conditional-intensity-information",
|
||||||
|
"updated_on": "2026-02-04",
|
||||||
|
"applies_to": "conditional intensity group descriptions"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reviewed_on": "2026-08-13",
|
||||||
|
"review_owner": "Weatherreporter maintainers",
|
||||||
|
"review_schedule": "Review annually and whenever SPC updates either referenced page."
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
"categorical:TSTM": {
|
"categorical:TSTM": {
|
||||||
"plain_language": "General or non-severe thunderstorms.",
|
"plain_language": "General or non-severe thunderstorms.",
|
||||||
"official_description": "No severe thunderstorms expected.",
|
"official_description": "Encloses a 10% or higher probability of thunderstorms.",
|
||||||
"relative_level": "0 of 5"
|
"relative_level": "0 of 5"
|
||||||
},
|
},
|
||||||
"categorical:MRGL": {
|
"categorical:MRGL": {
|
||||||
"plain_language": "Isolated severe storms possible.",
|
"plain_language": "Isolated severe storms possible.",
|
||||||
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
|
"official_description": "Includes severe storms of either limited organization and longevity or very low coverage.",
|
||||||
"relative_level": "1 of 5"
|
"relative_level": "1 of 5"
|
||||||
},
|
},
|
||||||
"categorical:SLGT": {
|
"categorical:SLGT": {
|
||||||
"plain_language": "Scattered severe storms possible.",
|
"plain_language": "Scattered severe storms possible.",
|
||||||
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
|
"official_description": "Implies organized severe thunderstorms are expected, but usually in low coverage with varying levels of intensity.",
|
||||||
"relative_level": "2 of 5"
|
"relative_level": "2 of 5"
|
||||||
},
|
},
|
||||||
"categorical:ENH": {
|
"categorical:ENH": {
|
||||||
"plain_language": "Numerous severe storms possible.",
|
"plain_language": "Numerous severe storms possible.",
|
||||||
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
|
"official_description": "Depicts a greater concentration of organized severe thunderstorms with varying levels of intensity.",
|
||||||
"relative_level": "3 of 5"
|
"relative_level": "3 of 5"
|
||||||
},
|
},
|
||||||
"categorical:MDT": {
|
"categorical:MDT": {
|
||||||
"plain_language": "Widespread severe storms likely.",
|
"plain_language": "Widespread severe storms likely.",
|
||||||
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
|
"official_description": "Indicates potential for widespread severe weather with several tornadoes and/or numerous severe thunderstorms, some of which may be intense.",
|
||||||
"relative_level": "4 of 5"
|
"relative_level": "4 of 5"
|
||||||
},
|
},
|
||||||
"categorical:HIGH": {
|
"categorical:HIGH": {
|
||||||
"plain_language": "Major severe outbreak expected.",
|
"plain_language": "Major severe outbreak expected.",
|
||||||
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
|
"official_description": "Suggests a severe weather outbreak is expected from either numerous intense to violent long-track tornadoes or a long-lived derecho system with hurricane-force wind gusts producing widespread damage.",
|
||||||
"relative_level": "5 of 5"
|
"relative_level": "5 of 5"
|
||||||
},
|
},
|
||||||
"tornado:CIG1": {
|
"tornado:CIG1": {
|
||||||
@@ -69,4 +87,5 @@
|
|||||||
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
"relative_level": "2 of 2"
|
"relative_level": "2 of 2"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
|||||||
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||||
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||||
}
|
}
|
||||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", 20)
|
||||||
if len(value) != 3 {
|
if len(value) != 3 {
|
||||||
t.Fatalf("periods length = %d, want 3", len(value))
|
t.Fatalf("periods length = %d, want 3", len(value))
|
||||||
}
|
}
|
||||||
@@ -462,19 +462,58 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
for _, tt := range []struct {
|
||||||
ID: module.AreaForecastDiscussion,
|
name string
|
||||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
options any
|
||||||
})
|
}{
|
||||||
|
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: tt.options,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
if output != nil {
|
||||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
|
||||||
}
|
}
|
||||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
}
|
||||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
|
||||||
|
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
story := moduleValue[WeatherStoryModule](t, output)
|
||||||
|
if !story.Available || story.Priority || story.Order != 0 {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +600,7 @@ func testModuleContext() ModuleContext {
|
|||||||
hourlyHumidity := 66.0
|
hourlyHumidity := 66.0
|
||||||
hourlyWindMph := 14.0
|
hourlyWindMph := 14.0
|
||||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
return ModuleContext{
|
ctx := ModuleContext{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Collected: facts.CollectedFacts{
|
Collected: facts.CollectedFacts{
|
||||||
Current: &weatherdata.Current{
|
Current: &weatherdata.Current{
|
||||||
@@ -689,6 +728,14 @@ func testModuleContext() ModuleContext {
|
|||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
ctx.Identity = BuildPreparedIdentity(BuildContext{
|
||||||
|
Resolved: ctx.Resolved,
|
||||||
|
Bundle: ctx.Collected.Bundle(),
|
||||||
|
Units: ctx.Units,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
Location: ctx.Location,
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type DerivedDailySummaryModule struct {
|
|||||||
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
ApparentTemperatureMaxF *int `json:"apparent_temperature_max_f,omitempty"`
|
||||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
Hazards []string `json:"hazards,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
|||||||
} else {
|
} else {
|
||||||
value.LowTempF = roundedInt(temperature.Min)
|
value.LowTempF = roundedInt(temperature.Min)
|
||||||
}
|
}
|
||||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
|
||||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||||
if narrativePrecipitation != nil {
|
if narrativePrecipitation != nil {
|
||||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||||
key := daypartKey(daypart, prefixDates)
|
key := daypartKey(daypart, prefixDates)
|
||||||
|
if existing, exists := value[key]; exists {
|
||||||
|
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
|
||||||
|
}
|
||||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
@@ -135,7 +137,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
temperature := daypartTemperatureDisplay(daypart)
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
DominantCondition: daypart.DominantCondition,
|
||||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
Snow: daypart.Indicators.Snow,
|
Snow: daypart.Indicators.Snow,
|
||||||
Ice: daypart.Indicators.Ice,
|
Ice: daypart.Indicators.Ice,
|
||||||
@@ -162,7 +164,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, hourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
if daypart.PeakWindGust != nil {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -301,18 +303,8 @@ func celsiusToFahrenheit(value float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func temperatureBandIndex(value int) int {
|
func temperatureBandIndex(value int) int {
|
||||||
decade := (value / 10) * 10
|
decade, remainder := temperatureBandParts(value)
|
||||||
remainder := value - decade
|
band := temperatureBandQualifierIndex(remainder)
|
||||||
if remainder < 0 {
|
|
||||||
remainder = -remainder
|
|
||||||
}
|
|
||||||
band := 1
|
|
||||||
switch {
|
|
||||||
case remainder <= 3:
|
|
||||||
band = 0
|
|
||||||
case remainder >= 7:
|
|
||||||
band = 2
|
|
||||||
}
|
|
||||||
return decade*3 + band
|
return decade*3 + band
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,29 +340,47 @@ func temperaturePhraseF(value forecast.Range) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func temperatureBandPhrase(value int) string {
|
func temperatureBandPhrase(value int) string {
|
||||||
decade := (value / 10) * 10
|
if value < 0 {
|
||||||
remainder := value - decade
|
decade, remainder := temperatureBandParts(-value)
|
||||||
if remainder < 0 {
|
qualifier := temperatureBandQualifier(remainder)
|
||||||
remainder = -remainder
|
if decade == 0 {
|
||||||
}
|
return fmt.Sprintf("%s single digits below zero", qualifier)
|
||||||
qualifier := "mid"
|
}
|
||||||
switch {
|
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
|
||||||
case remainder <= 3:
|
|
||||||
qualifier = "low"
|
|
||||||
case remainder >= 7:
|
|
||||||
qualifier = "upper"
|
|
||||||
}
|
}
|
||||||
|
decade, remainder := temperatureBandParts(value)
|
||||||
|
qualifier := temperatureBandQualifier(remainder)
|
||||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sentenceCase(value string) string {
|
func temperatureBandParts(value int) (int, int) {
|
||||||
trimmed := strings.TrimSpace(value)
|
decade := value / 10
|
||||||
if trimmed == "" {
|
if value < 0 && value%10 != 0 {
|
||||||
return ""
|
decade--
|
||||||
|
}
|
||||||
|
return decade * 10, value - decade*10
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandQualifierIndex(remainder int) int {
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
return 0
|
||||||
|
case remainder >= 7:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandQualifier(remainder int) string {
|
||||||
|
switch temperatureBandQualifierIndex(remainder) {
|
||||||
|
case 0:
|
||||||
|
return "low"
|
||||||
|
case 2:
|
||||||
|
return "upper"
|
||||||
|
default:
|
||||||
|
return "mid"
|
||||||
}
|
}
|
||||||
runes := []rune(trimmed)
|
|
||||||
runes[0] = unicode.ToUpper(runes[0])
|
|
||||||
return string(runes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
@@ -382,7 +392,7 @@ func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||||
key := normalizedKey(daypart.Name)
|
key := forecast.CanonicalDaypartKey(daypart.Name)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = "unnamed"
|
key = "unnamed"
|
||||||
}
|
}
|
||||||
@@ -391,21 +401,3 @@ func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
|||||||
}
|
}
|
||||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedKey(value string) string {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
var out strings.Builder
|
|
||||||
lastUnderscore := false
|
|
||||||
for _, r := range lower {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
||||||
out.WriteRune(r)
|
|
||||||
lastUnderscore = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !lastUnderscore {
|
|
||||||
out.WriteByte('_')
|
|
||||||
lastUnderscore = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Trim(out.String(), "_")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,19 +42,22 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
}
|
}
|
||||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
|
||||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal daily summary: %v", err)
|
t.Fatalf("marshal daily summary: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
|
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "apparent_temperature_max_f"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(jsonText, "heat_index") {
|
||||||
|
t.Fatalf("daily json = %s, want no heat-index label for generic apparent temperature", jsonText)
|
||||||
|
}
|
||||||
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||||
if strings.Contains(jsonText, removed) {
|
if strings.Contains(jsonText, removed) {
|
||||||
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
||||||
@@ -65,6 +68,54 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDailySummaryPreservesApparentTemperatureMeaning(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
temperature float64
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "hot", temperature: 101, want: 101},
|
||||||
|
{name: "mild", temperature: 63, want: 63},
|
||||||
|
{name: "below freezing", temperature: -12, want: -12},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
value, err := derivedDailySummaryValue(forecast.DailySummary{
|
||||||
|
Date: "2026-05-29",
|
||||||
|
Dayparts: []forecast.DaypartSummary{{
|
||||||
|
ApparentTemperature: forecast.Range{Max: floatPtr(tt.temperature)},
|
||||||
|
}},
|
||||||
|
}, forecast.PrecipTiming{}, "America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derivedDailySummaryValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != tt.want {
|
||||||
|
t.Fatalf("ApparentTemperatureMaxF = %#v, want %d", value.ApparentTemperatureMaxF, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDailySummaryLabelsMetricApparentTemperature(t *testing.T) {
|
||||||
|
start := mustParseModuleTime("2026-05-29T12:00:00-05:00")
|
||||||
|
period := timeutil.Period{Start: start, End: start.Add(time.Hour)}
|
||||||
|
daypart := forecast.SummarizeDaypart("afternoon", period, []weatherdata.ForecastPeriod{{
|
||||||
|
StartTime: period.Start,
|
||||||
|
EndTime: period.End,
|
||||||
|
TemperatureC: floatPtr(20),
|
||||||
|
ApparentTemperatureC: floatPtr(20),
|
||||||
|
}})
|
||||||
|
value, err := derivedDailySummaryValue(forecast.DailySummary{
|
||||||
|
Date: "2026-05-29",
|
||||||
|
Dayparts: []forecast.DaypartSummary{daypart},
|
||||||
|
}, forecast.PrecipTiming{}, "America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derivedDailySummaryValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 68 {
|
||||||
|
t.Fatalf("ApparentTemperatureMaxF = %#v, want converted 68", value.ApparentTemperatureMaxF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Daily)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
@@ -96,7 +147,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||||
}
|
}
|
||||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
|
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != 40 || !rainy.ThunderMentioned {
|
||||||
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||||
}
|
}
|
||||||
if len(rainy.PrecipitationWindows) != 2 {
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
@@ -215,7 +266,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
value := precipTimingValue(forecast.PrecipTiming{
|
value := precipTimingValue(forecast.PrecipTiming{
|
||||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
ProbabilityThreshold: 40,
|
||||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||||
{
|
{
|
||||||
Start: now,
|
Start: now,
|
||||||
@@ -223,7 +274,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
|||||||
Value: tt.maxPop,
|
Value: tt.maxPop,
|
||||||
Time: now,
|
Time: now,
|
||||||
},
|
},
|
||||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
ProbabilityThreshold: 40,
|
||||||
TextDescriptions: tt.descriptions,
|
TextDescriptions: tt.descriptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -304,6 +355,52 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesRejectCanonicalKeyCollisions(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
first := ctx.Derived.DaypartSummaries[0]
|
||||||
|
first.Name = "Morning"
|
||||||
|
second := first
|
||||||
|
second.Name = "morning!"
|
||||||
|
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||||
|
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "collides") {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want canonical daypart-key collision", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummaryDisplayCapitalizesUnicodeNames(t *testing.T) {
|
||||||
|
value := derivedDaypartSummaryValue(forecast.DaypartSummary{
|
||||||
|
Name: "mañana",
|
||||||
|
DominantCondition: "llovizna",
|
||||||
|
}, "UTC")
|
||||||
|
if value.DisplayName != "Mañana" || value.DominantConditionDisplay != "Llovizna" {
|
||||||
|
t.Fatalf("daypart display = %#v, want rune-safe capitalization", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesKeepDistinctUnicodeKeys(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
first := ctx.Derived.DaypartSummaries[0]
|
||||||
|
first.Name = "mañana"
|
||||||
|
second := first
|
||||||
|
second.Name = "manana"
|
||||||
|
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||||
|
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||||
|
if len(value) != 2 || value["mañana"].DisplayName != "Mañana" || value["manana"].DisplayName != "Manana" {
|
||||||
|
t.Fatalf("daypart summaries = %#v, want distinct Unicode canonical keys", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Daily)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
@@ -390,6 +487,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
|||||||
wantTrend: "steady",
|
wantTrend: "steady",
|
||||||
wantSteady: "upper 70s",
|
wantSteady: "upper 70s",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "rising across zero",
|
||||||
|
temps: []float64{-5, 5},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "mid single digits below zero",
|
||||||
|
wantEnd: "mid 0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling across zero",
|
||||||
|
temps: []float64{5, -5},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 0s",
|
||||||
|
wantEnd: "mid single digits below zero",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -460,6 +571,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
|||||||
wantTrend: "steady",
|
wantTrend: "steady",
|
||||||
wantSteady: "upper 70s",
|
wantSteady: "upper 70s",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "rising across zero",
|
||||||
|
temps: []float64{-5, 5},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "mid single digits below zero",
|
||||||
|
wantEnd: "mid 0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling across zero",
|
||||||
|
temps: []float64{5, -5},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 0s",
|
||||||
|
wantEnd: "mid single digits below zero",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -506,6 +631,11 @@ func TestTemperaturePhraseF(t *testing.T) {
|
|||||||
value: forecast.Range{Max: floatPtr(84)},
|
value: forecast.Range{Max: floatPtr(84)},
|
||||||
want: "mid 80s",
|
want: "mid 80s",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "below zero range",
|
||||||
|
value: forecast.Range{Min: floatPtr(-9), Max: floatPtr(-1)},
|
||||||
|
want: "upper single digits below zero to low single digits below zero",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty",
|
name: "empty",
|
||||||
value: forecast.Range{},
|
value: forecast.Range{},
|
||||||
@@ -521,6 +651,130 @@ func TestTemperaturePhraseF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTemperatureBandIndexPreservesSignedOrder(t *testing.T) {
|
||||||
|
values := []int{-11, -10, -9, -5, -1, 0, 1, 9}
|
||||||
|
previous := temperatureBandIndex(values[0])
|
||||||
|
for _, value := range values[1:] {
|
||||||
|
current := temperatureBandIndex(value)
|
||||||
|
if current < previous {
|
||||||
|
t.Fatalf("temperatureBandIndex(%d) = %d, want at least %d", value, current, previous)
|
||||||
|
}
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
for _, tt := range []struct {
|
||||||
|
value int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{value: -11, want: "low 10s below zero"},
|
||||||
|
{value: -10, want: "low 10s below zero"},
|
||||||
|
{value: -9, want: "upper single digits below zero"},
|
||||||
|
{value: -5, want: "mid single digits below zero"},
|
||||||
|
{value: -1, want: "low single digits below zero"},
|
||||||
|
{value: 0, want: "low 0s"},
|
||||||
|
{value: 1, want: "low 0s"},
|
||||||
|
{value: 9, want: "upper 0s"},
|
||||||
|
} {
|
||||||
|
if got := temperatureBandPhrase(tt.value); got != tt.want {
|
||||||
|
t.Fatalf("temperatureBandPhrase(%d) = %q, want %q", tt.value, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
indicators forecast.Indicators
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{name: "snow", indicators: forecast.Indicators{Snow: true}, reason: "snow risk"},
|
||||||
|
{name: "ice", indicators: forecast.Indicators{Ice: true}, reason: "ice risk"},
|
||||||
|
{name: "fog", indicators: forecast.Indicators{Fog: true}, reason: "fog risk"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
window := scoreOutdoorWindow(forecast.DaypartSummary{Name: tt.name, Indicators: tt.indicators})
|
||||||
|
if window.Score != outdoorIndicatorRiskScore || !containsString(window.Reasons, tt.reason) || containsString(window.Reasons, "quiet weather") {
|
||||||
|
t.Fatalf("outdoor window = %#v, want indicator risk without quiet weather", window)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dayparts := []forecast.DaypartSummary{
|
||||||
|
{Name: "snow", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
|
||||||
|
{Name: "ice and fog", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true, Fog: true}},
|
||||||
|
}
|
||||||
|
windows := buildOutdoorWindows(dayparts)
|
||||||
|
if windows.Best == nil || windows.Best.Daypart != "snow" || windows.Worst == nil || windows.Worst.Daypart != "ice and fog" {
|
||||||
|
t.Fatalf("outdoor windows = %#v, want mixed hazards ranked by accumulated risk", windows)
|
||||||
|
}
|
||||||
|
|
||||||
|
tied := buildOutdoorWindows([]forecast.DaypartSummary{
|
||||||
|
{Name: "first", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
|
||||||
|
{Name: "second", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true}},
|
||||||
|
})
|
||||||
|
if tied.Best == nil || tied.Best.Daypart != "first" || tied.Worst == nil || tied.Worst.Daypart != "first" {
|
||||||
|
t.Fatalf("tied outdoor windows = %#v, want input-order tie behavior", tied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanningUsesCanonicalDaypartIdentities(t *testing.T) {
|
||||||
|
timedValue := func(value float64) *forecast.TimedValue {
|
||||||
|
return &forecast.TimedValue{Value: value}
|
||||||
|
}
|
||||||
|
containsText := func(values []string, text string) bool {
|
||||||
|
return strings.Contains(strings.Join(values, "\n"), text)
|
||||||
|
}
|
||||||
|
summary := &forecast.DailySummary{Dayparts: []forecast.DaypartSummary{
|
||||||
|
{Name: "Overnight!", MaxPrecipitationProbability: timedValue(60)},
|
||||||
|
{Name: "MORNING", MaxPrecipitationProbability: timedValue(60)},
|
||||||
|
{Name: "Afternoon!!!", MaxPrecipitationProbability: timedValue(60)},
|
||||||
|
{Name: "EVENING!", MaxPrecipitationProbability: timedValue(60)},
|
||||||
|
}}
|
||||||
|
|
||||||
|
today := buildTodayPlanning(summary)
|
||||||
|
if !containsText(today.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("today morning readiness = %#v, want canonical morning window", today.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsText(today.CommuteSchoolWorkdayConcerns, "Afternoon!!! precipitation chance reaches 60%.") ||
|
||||||
|
containsText(today.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||||
|
containsText(today.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||||
|
t.Fatalf("today workday concerns = %#v, want only canonical workday windows", today.CommuteSchoolWorkdayConcerns)
|
||||||
|
}
|
||||||
|
if !containsText(today.LateDayChangeWatch, "Afternoon!!! precipitation timing may shift") ||
|
||||||
|
!containsText(today.LateDayChangeWatch, "EVENING! precipitation timing may shift") {
|
||||||
|
t.Fatalf("today late-day watch = %#v, want canonical afternoon and evening windows", today.LateDayChangeWatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
if !containsText(base.MorningReadiness, "Morning precipitation chance peaks near 60%.") ||
|
||||||
|
!containsText(base.OvernightChangeWatch, "Overnight precipitation timing may shift") ||
|
||||||
|
containsText(base.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||||
|
containsText(base.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||||
|
t.Fatalf("daily/tomorrow planning = %#v, want canonical daypart treatment", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapitalizeFirst(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", input: "", want: ""},
|
||||||
|
{name: "ASCII", input: "morning", want: "Morning"},
|
||||||
|
{name: "multibyte", input: "mañana", want: "Mañana"},
|
||||||
|
{name: "already uppercase", input: "Morning", want: "Morning"},
|
||||||
|
{name: "leading space", input: " morning", want: " morning"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := capitalizeFirst(tt.input); got != tt.want {
|
||||||
|
t.Fatalf("capitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Tomorrow)
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|||||||
85
internal/briefing/fact_requirements.go
Normal file
85
internal/briefing/fact_requirements.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
type factRequirementCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
collectedFactRequirement factRequirementCategory = "collected"
|
||||||
|
derivedFactRequirement factRequirementCategory = "derived"
|
||||||
|
)
|
||||||
|
|
||||||
|
type factRequirement struct {
|
||||||
|
identity string
|
||||||
|
category factRequirementCategory
|
||||||
|
available func(ModuleContext) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r factRequirement) String() string {
|
||||||
|
return r.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
currentConditionsRequirement = &factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.Current != nil
|
||||||
|
}}
|
||||||
|
narrativeForecastRequirement = &factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.Narrative != nil
|
||||||
|
}}
|
||||||
|
hourlyForecastRequirement = &factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.Hourly != nil
|
||||||
|
}}
|
||||||
|
alertsRequirement = &factRequirement{identity: "collected.alerts", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.Alerts != nil
|
||||||
|
}}
|
||||||
|
discussionRequirement = &factRequirement{identity: "collected.discussion", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.Discussion != nil
|
||||||
|
}}
|
||||||
|
weatherStoryRequirement = &factRequirement{identity: "collected.weather_story", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
}}
|
||||||
|
spcOutlooksRequirement = &factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
|
}}
|
||||||
|
sourceMetadataRequirement = &factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
|
}}
|
||||||
|
|
||||||
|
hourlyPeriodsRequirement = &factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||||
|
}}
|
||||||
|
narrativePeriodsRequirement = &factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||||
|
}}
|
||||||
|
alertOverlapsRequirement = &factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement, available: func(ModuleContext) bool {
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
dailySummariesRequirement = &factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return len(ctx.Derived.DailySummaries) > 0
|
||||||
|
}}
|
||||||
|
daypartSummariesRequirement = &factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
|
}}
|
||||||
|
precipTimingRequirement = &factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement, available: func(ModuleContext) bool {
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
spcDerivedOutlooksRequirement = &factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||||
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
|
||||||
|
var factRequirementVocabulary = []*factRequirement{
|
||||||
|
currentConditionsRequirement,
|
||||||
|
narrativeForecastRequirement,
|
||||||
|
hourlyForecastRequirement,
|
||||||
|
alertsRequirement,
|
||||||
|
discussionRequirement,
|
||||||
|
weatherStoryRequirement,
|
||||||
|
spcOutlooksRequirement,
|
||||||
|
sourceMetadataRequirement,
|
||||||
|
hourlyPeriodsRequirement,
|
||||||
|
narrativePeriodsRequirement,
|
||||||
|
alertOverlapsRequirement,
|
||||||
|
dailySummariesRequirement,
|
||||||
|
daypartSummariesRequirement,
|
||||||
|
precipTimingRequirement,
|
||||||
|
spcDerivedOutlooksRequirement,
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
const hourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||||
|
|
||||||
type HourlyForecastModule struct {
|
type HourlyForecastModule struct {
|
||||||
Product string `json:"product,omitempty"`
|
Product string `json:"product,omitempty"`
|
||||||
@@ -184,7 +184,7 @@ func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, hourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||||
|
|||||||
@@ -31,18 +31,17 @@ type SourceWarningSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
metadata := ctx.Resolved.Metadata()
|
|
||||||
value := MetadataModule{
|
value := MetadataModule{
|
||||||
RunID: metadata.RunID,
|
RunID: ctx.Identity.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: ctx.Identity.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: ctx.Identity.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: ctx.Identity.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: ctx.Identity.GeneratedAt,
|
||||||
Units: ctx.Units,
|
Units: ctx.Identity.Units,
|
||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Identity.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: ctx.Identity.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Identity.Location),
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ModuleContext struct {
|
type ModuleContext struct {
|
||||||
|
Identity PreparedIdentity
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Collected facts.CollectedFacts
|
Collected facts.CollectedFacts
|
||||||
Derived facts.DerivedFacts
|
Derived facts.DerivedFacts
|
||||||
@@ -27,8 +28,8 @@ type ModuleDefinition struct {
|
|||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
DefaultOptions any
|
DefaultOptions any
|
||||||
RequiredCollected []module.FactRequirement
|
RequiredCollected []*factRequirement
|
||||||
RequiredDerived []module.FactRequirement
|
RequiredDerived []*factRequirement
|
||||||
SupportedReports []report.ID
|
SupportedReports []report.ID
|
||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
@@ -75,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
|||||||
if definition.MissingData == module.MissingDataWarn {
|
if definition.MissingData == module.MissingDataWarn {
|
||||||
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
||||||
}
|
}
|
||||||
|
if err := validateFactRequirements(definition); err != nil {
|
||||||
|
return ModuleRegistry{}, err
|
||||||
|
}
|
||||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||||
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||||
}
|
}
|
||||||
@@ -100,7 +104,8 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||||
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
options, err := definition.CanonicalOptions(item.Options)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if definition.Builder == nil {
|
if definition.Builder == nil {
|
||||||
@@ -120,7 +125,6 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options := item.Options
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
options = definition.DefaultOptions
|
options = definition.DefaultOptions
|
||||||
}
|
}
|
||||||
@@ -152,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, requirement := range definition.RequiredCollected {
|
for _, requirement := range definition.RequiredCollected {
|
||||||
if !collectedFactAvailable(requirement, ctx) {
|
if !requirement.available(ctx) {
|
||||||
missing = append(missing, string(requirement))
|
missing = append(missing, requirement.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, requirement := range definition.RequiredDerived {
|
for _, requirement := range definition.RequiredDerived {
|
||||||
if !derivedFactAvailable(requirement, ctx) {
|
if !requirement.available(ctx) {
|
||||||
missing = append(missing, string(requirement))
|
missing = append(missing, requirement.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
func validateFactRequirements(definition ModuleDefinition) error {
|
||||||
switch requirement {
|
if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
|
||||||
case module.CollectedCurrentConditions:
|
return err
|
||||||
return ctx.Collected.Current != nil
|
|
||||||
case module.CollectedNarrativeForecast:
|
|
||||||
return ctx.Collected.Narrative != nil
|
|
||||||
case module.CollectedHourlyForecast:
|
|
||||||
return ctx.Collected.Hourly != nil
|
|
||||||
case module.CollectedAlerts:
|
|
||||||
return ctx.Collected.Alerts != nil
|
|
||||||
case module.CollectedDiscussion:
|
|
||||||
return ctx.Collected.Discussion != nil
|
|
||||||
case module.CollectedWeatherStory:
|
|
||||||
return ctx.Collected.WeatherStory != nil
|
|
||||||
case module.CollectedSPCConvectiveOutlooks:
|
|
||||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
|
||||||
case module.CollectedSourceMetadata:
|
|
||||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
|
||||||
}
|
}
|
||||||
|
|
||||||
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
|
||||||
switch requirement {
|
for _, requirement := range requirements {
|
||||||
case module.RequiresDerivedHourlyPeriods:
|
if requirement == nil {
|
||||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
|
||||||
case module.RequiresDerivedNarrativePeriods:
|
}
|
||||||
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
descriptor, ok := lookupFactRequirement(requirement.identity)
|
||||||
case module.RequiresDerivedAlertOverlaps:
|
if !ok || descriptor != requirement {
|
||||||
return true
|
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity)
|
||||||
case module.RequiresDerivedDailySummaries:
|
}
|
||||||
return len(ctx.Derived.DailySummaries) > 0
|
if descriptor.category != want {
|
||||||
case module.RequiresDerivedDaypartSummaries:
|
return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want)
|
||||||
return len(ctx.Derived.DaypartSummaries) > 0
|
}
|
||||||
case module.RequiresDerivedPrecipTiming:
|
|
||||||
return true
|
|
||||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
|
||||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupFactRequirement(identity string) (*factRequirement, bool) {
|
||||||
|
for _, requirement := range factRequirementVocabulary {
|
||||||
|
if requirement.identity == identity {
|
||||||
|
return requirement, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||||
@@ -264,6 +256,24 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanonicalOptions validates options and converts an accepted typed pointer to its value form.
|
||||||
|
func (d ModuleDefinition) CanonicalOptions(options any) (any, error) {
|
||||||
|
if err := d.ValidateOptions(options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if options == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := reflect.ValueOf(options)
|
||||||
|
if value.Kind() != reflect.Pointer {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, fmt.Errorf("module %q options must not be a nil pointer", d.ID)
|
||||||
|
}
|
||||||
|
return value.Elem().Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||||
@@ -272,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
StanzaName: "metadata",
|
StanzaName: "metadata",
|
||||||
DefaultOptions: module.MetadataOptions{},
|
DefaultOptions: module.MetadataOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
RequiredCollected: []*factRequirement{sourceMetadataRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildMetadataModule,
|
Builder: buildMetadataModule,
|
||||||
@@ -281,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.CurrentConditions,
|
ID: module.CurrentConditions,
|
||||||
StanzaName: "current_conditions",
|
StanzaName: "current_conditions",
|
||||||
DefaultOptions: module.CurrentConditionsOptions{},
|
DefaultOptions: module.CurrentConditionsOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
RequiredCollected: []*factRequirement{currentConditionsRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
@@ -291,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
StanzaName: "narrative_forecast",
|
StanzaName: "narrative_forecast",
|
||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []*factRequirement{narrativeForecastRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
@@ -301,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.HourlyForecast,
|
ID: module.HourlyForecast,
|
||||||
StanzaName: "hourly_forecast",
|
StanzaName: "hourly_forecast",
|
||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []*factRequirement{hourlyForecastRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
@@ -312,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
@@ -321,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DerivedDaypartSummaries,
|
ID: module.DerivedDaypartSummaries,
|
||||||
StanzaName: "derived_daypart_summaries",
|
StanzaName: "derived_daypart_summaries",
|
||||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
@@ -331,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
DefaultOptions: module.PrecipTimingOptions{},
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []*factRequirement{precipTimingRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildPrecipTimingModule,
|
Builder: buildPrecipTimingModule,
|
||||||
@@ -340,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.AlertDigest,
|
ID: module.AlertDigest,
|
||||||
StanzaName: "alert_digest",
|
StanzaName: "alert_digest",
|
||||||
DefaultOptions: module.AlertDigestOptions{},
|
DefaultOptions: module.AlertDigestOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
RequiredCollected: []*factRequirement{alertsRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
RequiredDerived: []*factRequirement{alertOverlapsRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildAlertDigestModule,
|
Builder: buildAlertDigestModule,
|
||||||
@@ -350,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.SPCConvectiveOutlooks,
|
ID: module.SPCConvectiveOutlooks,
|
||||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildSPCConvectiveOutlooksModule,
|
Builder: buildSPCConvectiveOutlooksModule,
|
||||||
@@ -360,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
StanzaName: "area_forecast_discussion",
|
StanzaName: "area_forecast_discussion",
|
||||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
RequiredCollected: []*factRequirement{discussionRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
@@ -369,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.SPCConvectiveDiscussion,
|
ID: module.SPCConvectiveDiscussion,
|
||||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildSPCConvectiveDiscussionModule,
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
@@ -379,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
DefaultOptions: module.WeatherStoryOptions{},
|
DefaultOptions: module.WeatherStoryOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
RequiredCollected: []*factRequirement{weatherStoryRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildWeatherStoryModule,
|
Builder: buildWeatherStoryModule,
|
||||||
@@ -388,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.OutdoorWindows,
|
ID: module.OutdoorWindows,
|
||||||
StanzaName: "outdoor_windows",
|
StanzaName: "outdoor_windows",
|
||||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildOutdoorWindowsModule,
|
Builder: buildOutdoorWindowsModule,
|
||||||
@@ -397,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.TodayPlanning,
|
ID: module.TodayPlanning,
|
||||||
StanzaName: "today_planning",
|
StanzaName: "today_planning",
|
||||||
DefaultOptions: module.TodayPlanningOptions{},
|
DefaultOptions: module.TodayPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Today},
|
SupportedReports: []report.ID{report.Today},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTodayPlanningModule,
|
Builder: buildTodayPlanningModule,
|
||||||
@@ -406,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.TomorrowPlanning,
|
ID: module.TomorrowPlanning,
|
||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Tomorrow},
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
@@ -415,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DailyPlanning,
|
ID: module.DailyPlanning,
|
||||||
StanzaName: "daily_planning",
|
StanzaName: "daily_planning",
|
||||||
DefaultOptions: module.DailyPlanningOptions{},
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily},
|
SupportedReports: []report.ID{report.Daily},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildDailyPlanningModule,
|
Builder: buildDailyPlanningModule,
|
||||||
|
|||||||
@@ -30,6 +30,55 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
|
||||||
|
vocabulary := make(map[string]*factRequirement, len(factRequirementVocabulary))
|
||||||
|
for _, requirement := range factRequirementVocabulary {
|
||||||
|
if requirement.identity == "" {
|
||||||
|
t.Fatal("fact requirement identity is empty")
|
||||||
|
}
|
||||||
|
if requirement.category != collectedFactRequirement && requirement.category != derivedFactRequirement {
|
||||||
|
t.Fatalf("fact requirement %q category = %q, want collected or derived", requirement.identity, requirement.category)
|
||||||
|
}
|
||||||
|
if _, exists := vocabulary[requirement.identity]; exists {
|
||||||
|
t.Fatalf("duplicate fact requirement %q", requirement.identity)
|
||||||
|
}
|
||||||
|
if requirement.available == nil {
|
||||||
|
t.Fatalf("fact requirement %q has no availability predicate", requirement.identity)
|
||||||
|
}
|
||||||
|
vocabulary[requirement.identity] = requirement
|
||||||
|
}
|
||||||
|
|
||||||
|
used := map[*factRequirement]struct{}{}
|
||||||
|
for _, definition := range defaultModuleDefinitions() {
|
||||||
|
for _, requirement := range definition.RequiredCollected {
|
||||||
|
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement)
|
||||||
|
}
|
||||||
|
for _, requirement := range definition.RequiredDerived {
|
||||||
|
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, derivedFactRequirement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, requirement := range factRequirementVocabulary {
|
||||||
|
if _, ok := used[requirement]; !ok {
|
||||||
|
t.Fatalf("fact requirement %q is not used by a module definition", requirement.identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]*factRequirement, used map[*factRequirement]struct{}, moduleID module.ID, requirement *factRequirement, want factRequirementCategory) {
|
||||||
|
t.Helper()
|
||||||
|
descriptor, ok := vocabulary[requirement.identity]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("module %q uses unknown fact requirement %q", moduleID, requirement.identity)
|
||||||
|
}
|
||||||
|
if descriptor != requirement {
|
||||||
|
t.Fatalf("module %q requirement %q does not use the vocabulary descriptor", moduleID, requirement.identity)
|
||||||
|
}
|
||||||
|
if requirement.category != want {
|
||||||
|
t.Fatalf("module %q requirement %q category = %q, want %q", moduleID, requirement.identity, requirement.category, want)
|
||||||
|
}
|
||||||
|
used[requirement] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
for _, definition := range report.DefaultRegistry().All() {
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
@@ -434,6 +483,52 @@ func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsInvalidFactRequirements(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*ModuleDefinition)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Unknown",
|
||||||
|
configure: func(definition *ModuleDefinition) {
|
||||||
|
definition.RequiredCollected = []*factRequirement{{identity: "collected.unknown", category: collectedFactRequirement}}
|
||||||
|
},
|
||||||
|
wantErr: `module "metadata" uses unknown collected fact requirement "collected.unknown"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DerivedListedAsCollected",
|
||||||
|
configure: func(definition *ModuleDefinition) {
|
||||||
|
definition.RequiredCollected = []*factRequirement{dailySummariesRequirement}
|
||||||
|
},
|
||||||
|
wantErr: `module "metadata" lists derived fact requirement "derived.daily_summaries" as collected`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CollectedListedAsDerived",
|
||||||
|
configure: func(definition *ModuleDefinition) {
|
||||||
|
definition.RequiredDerived = []*factRequirement{currentConditionsRequirement}
|
||||||
|
},
|
||||||
|
wantErr: `module "metadata" lists collected fact requirement "collected.current_conditions" as derived`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
definition := ModuleDefinition{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
DefaultOptions: module.MetadataOptions{},
|
||||||
|
Builder: noopModuleBuilder,
|
||||||
|
}
|
||||||
|
tt.configure(&definition)
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{definition})
|
||||||
|
if err == nil || err.Error() != tt.wantErr {
|
||||||
|
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
||||||
@@ -458,6 +553,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
|
{ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateComposition() error = %v", err)
|
t.Fatalf("ValidateComposition() error = %v", err)
|
||||||
@@ -466,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||||
ctx := ModuleContext{}
|
ctx := ModuleContext{}
|
||||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
if spcOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
t.Fatal("SPC outlook requirement is available, want false without source")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
if !spcOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||||
ctx := ModuleContext{}
|
ctx := ModuleContext{}
|
||||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
if spcDerivedOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
if !spcDerivedOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
t.Fatal("derived SPC outlook requirement is unavailable, want true for checked empty derived outlooks")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Metadata struct {
|
// PreparedIdentity is the single prepared authority for report identity,
|
||||||
|
// timing, configuration context, and source warnings.
|
||||||
|
type PreparedIdentity struct {
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
@@ -21,9 +23,7 @@ type Metadata struct {
|
|||||||
Location *LocationContext `json:"location,omitempty"`
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
|
||||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationContext struct {
|
type LocationContext struct {
|
||||||
@@ -33,24 +33,6 @@ type LocationContext struct {
|
|||||||
Timezone string `json:"timezone,omitempty"`
|
Timezone string `json:"timezone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceMetadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
||||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
|
||||||
Missing bool `json:"missing,omitempty"`
|
|
||||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertStatus struct {
|
|
||||||
Checked bool `json:"checked"`
|
|
||||||
ActiveCount int `json:"activeCount"`
|
|
||||||
RelevantCount int `json:"relevantCount"`
|
|
||||||
Missing bool `json:"missing,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildContext struct {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *weatherdata.Bundle
|
Bundle *weatherdata.Bundle
|
||||||
@@ -59,10 +41,10 @@ type BuildContext struct {
|
|||||||
Location *LocationContext
|
Location *LocationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(ctx BuildContext) Metadata {
|
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
|
||||||
metadata := ctx.Resolved.Metadata()
|
metadata := ctx.Resolved.Metadata()
|
||||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||||
return Metadata{
|
return PreparedIdentity{
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: variantForReport(metadata.ReportID),
|
||||||
@@ -74,9 +56,7 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Location),
|
||||||
SourceLocationID: sourceLocationID,
|
SourceLocationID: sourceLocationID,
|
||||||
SourceLocation: sourceLocation,
|
SourceLocation: sourceLocation,
|
||||||
Sources: sourceMetadata(ctx.Bundle),
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
|
||||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
|
||||||
Alerts: alertStatus(ctx.Bundle),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
|
|
||||||
if bundle == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]SourceMetadata, 0, len(bundle.Sources))
|
|
||||||
for _, source := range bundle.Sources {
|
|
||||||
out = append(out, SourceMetadata{
|
|
||||||
Name: source.Name,
|
|
||||||
Endpoint: source.Endpoint,
|
|
||||||
FetchedAt: source.FetchedAt,
|
|
||||||
IssuedAt: source.IssuedAt,
|
|
||||||
UpdatedAt: source.UpdatedAt,
|
|
||||||
DataSHA256: source.DataSHA256,
|
|
||||||
Missing: source.Missing,
|
|
||||||
Warnings: source.Warnings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -162,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
|||||||
return bundle.Warnings
|
return bundle.Warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|
||||||
if bundle == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
status := &AlertStatus{}
|
|
||||||
if bundle.Alerts != nil {
|
|
||||||
status.Checked = true
|
|
||||||
status.ActiveCount = len(bundle.Alerts.Alerts)
|
|
||||||
}
|
|
||||||
for _, source := range bundle.Sources {
|
|
||||||
if source.Name == "alerts" && source.Missing {
|
|
||||||
status.Missing = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !status.Checked && !status.Missing {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.Daily, report.Today:
|
case report.Daily, report.Today:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
|
|||||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
|
||||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||||
default:
|
default:
|
||||||
|
|||||||
26
internal/briefing/prepared_identity_test.go
Normal file
26
internal/briefing/prepared_identity_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedIdentityCopiesMutableFields(t *testing.T) {
|
||||||
|
moduleContext := testModuleContext()
|
||||||
|
context := BuildContext{
|
||||||
|
Resolved: moduleContext.Resolved,
|
||||||
|
Bundle: moduleContext.Collected.Bundle(),
|
||||||
|
Units: moduleContext.Units,
|
||||||
|
Timezone: moduleContext.Timezone,
|
||||||
|
Location: moduleContext.Location,
|
||||||
|
}
|
||||||
|
identity := BuildPreparedIdentity(context)
|
||||||
|
originalWarnings := append([]weatherdata.SourceWarning(nil), moduleContext.Collected.SourceWarnings...)
|
||||||
|
identity.Location.Name = "consumer mutation"
|
||||||
|
identity.SourceWarnings = append(identity.SourceWarnings, moduleContext.Collected.SourceWarnings[0])
|
||||||
|
if moduleContext.Location.Name == "consumer mutation" || !reflect.DeepEqual(moduleContext.Collected.SourceWarnings, originalWarnings) {
|
||||||
|
t.Fatalf("prepared identity changed its source context: %#v", identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,29 @@ func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||||
}
|
}
|
||||||
var definitions map[string]SPCOutlookBackgroundDefinition
|
var asset spcOutlookBackgroundDefinitionAsset
|
||||||
if err := json.Unmarshal(data, &definitions); err != nil {
|
if err := json.Unmarshal(data, &asset); err != nil {
|
||||||
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
||||||
}
|
}
|
||||||
return definitions
|
return asset.Definitions
|
||||||
|
}
|
||||||
|
|
||||||
|
type spcOutlookBackgroundDefinitionAsset struct {
|
||||||
|
Provenance spcOutlookDefinitionProvenance `json:"provenance"`
|
||||||
|
Definitions map[string]SPCOutlookBackgroundDefinition `json:"definitions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type spcOutlookDefinitionProvenance struct {
|
||||||
|
Sources []spcOutlookDefinitionSource `json:"sources"`
|
||||||
|
ReviewedOn string `json:"reviewed_on"`
|
||||||
|
ReviewOwner string `json:"review_owner"`
|
||||||
|
ReviewSchedule string `json:"review_schedule"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type spcOutlookDefinitionSource struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
UpdatedOn string `json:"updated_on"`
|
||||||
|
AppliesTo string `json:"applies_to"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
|
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user