Compare commits
100 Commits
eed47b4f68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bafbcb21f | |||
| 7005688b80 | |||
| 4cd5f505df | |||
| b3b23fb381 | |||
| 0c9cd6d5fb | |||
| ce79ea92c5 | |||
| 20107b0dfd | |||
| 1b38f66240 | |||
| b92f83e49b | |||
| 24a8579cee | |||
| 515cdada04 | |||
| 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 |
@@ -3,14 +3,29 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
||||
)
|
||||
|
||||
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)
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
docs/cli.md
43
docs/cli.md
@@ -68,6 +68,13 @@ 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
|
||||
@@ -89,7 +96,7 @@ period, prompt version, timezone, and status. Successful output has an absolute
|
||||
"command": "generate",
|
||||
"reportId": "today",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"promptVersion": "2.0.0",
|
||||
"promptVersion": "2.1.0",
|
||||
"runId": "20260529T120000.000000000Z_today",
|
||||
"status": "succeeded",
|
||||
"timezone": "America/Chicago",
|
||||
@@ -99,7 +106,10 @@ period, prompt version, timezone, and status. Successful output has an absolute
|
||||
|
||||
When available, the summary also includes the effective `profileId`,
|
||||
`backendId`, `modelName`, `sourceWarnings`, `validationStatus`, requested
|
||||
`llmDebugPath`, and compact Distributor `notification` result. It does not
|
||||
`repairAttempts`, `llmDebugPath`, and compact Distributor `notification`
|
||||
result. `repairAttempts` is `0` when the initial output passed validation,
|
||||
positive when PromptKit made corrective generation calls, and omitted when
|
||||
validation did not complete. The summary does not
|
||||
include historical or transient artifact paths such as metadata, prompt input,
|
||||
raw generated text, render context, or notification receipts.
|
||||
|
||||
@@ -108,18 +118,23 @@ raw generated text, render context, or notification receipts.
|
||||
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
|
||||
`total`, `succeeded`, `failed`, and a `reports` array. Each report item includes
|
||||
its identity, status, effective profile and model details when available,
|
||||
source warnings, validation status, and absolute `outputPath` after publication.
|
||||
source warnings, validation status, repair-attempt count when validation
|
||||
completed, 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:
|
||||
|
||||
```text
|
||||
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
|
||||
@@ -132,7 +147,8 @@ A comparison summary contains these fields in this order: `command`,
|
||||
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`.
|
||||
`repairAttempts`, optional `reportPath`, optional `llmDebugPath`, and optional
|
||||
safe `error`. The repair-attempt semantics match the generate summary.
|
||||
|
||||
The comparison status is `succeeded` only when every selected profile succeeds
|
||||
and the bundle is published. Individual profile failures still publish a
|
||||
@@ -143,16 +159,19 @@ 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`. It does not expose
|
||||
provider diagnostics, filesystem causes, or recovery paths. See the
|
||||
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. A provider HTTP
|
||||
failure may include its numeric status in the safe message. 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` with the message `comparison published
|
||||
but cleanup did not complete`; the returned command error identifies the
|
||||
retained backup path for operator recovery.
|
||||
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
|
||||
|
||||
@@ -163,11 +182,11 @@ retained backup path for operator recovery.
|
||||
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
|
||||
| `--tz NAME` | `generate`, `run`, `compare` | Override `weather_api.timezone` for this command. |
|
||||
| `--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`, `run`, and `compare` command | Write requested sensitive prompt diagnostics under this absolute path. |
|
||||
| `--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. |
|
||||
| `--profile PROFILE` | `compare` | Select one explicit profile. Repeat at least twice with distinct, nonblank IDs. |
|
||||
| `--out-dir PATH` | `run morning`, `run evening`, `compare` | Write batch reports beneath this directory, or select the exact comparison directory. |
|
||||
| `--replace` | `compare` | Authorize replacement of a recognized nonempty comparison bundle. |
|
||||
| `--quiet` | `generate`, `run`, `compare` | Suppress successful action output and routine batch status output. |
|
||||
| `--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
|
||||
|
||||
@@ -45,7 +45,7 @@ All omitted fields use their built-in defaults.
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
@@ -53,7 +53,11 @@ All omitted fields use their built-in defaults.
|
||||
| `format` | `json` | Required and must be `json`. |
|
||||
|
||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||
`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`
|
||||
|
||||
@@ -72,8 +76,10 @@ The prompt-facing location timezone is derived from the effective
|
||||
### `secrets`
|
||||
|
||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||
is set, every regular file directly in that directory is loaded after the file
|
||||
and command-line overrides. A file basename must match
|
||||
is set, every regular file directly in that directory is staged after the file
|
||||
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
|
||||
file contents replace any existing value. One trailing LF or CRLF is removed.
|
||||
|
||||
@@ -114,7 +120,7 @@ Distributor notification is disabled by default. Its fields are:
|
||||
| Field | Default | Rules when notification is enabled |
|
||||
| --- | --- | --- |
|
||||
| `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. |
|
||||
| `timeout` | `30s` | Must be greater than zero. |
|
||||
| `failure_policy` | `error` | Must be `error`. |
|
||||
@@ -129,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`.
|
||||
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`,
|
||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||
@@ -161,9 +175,10 @@ output selection, and failure handling.
|
||||
|
||||
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||
Hourly forecast data is required for generated reports. Supported optional
|
||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
||||
`weather_story`, and `spc_convective_outlooks`.
|
||||
Hourly forecast data is required for generated reports and cannot have a
|
||||
source-specific policy. Supported optional source keys are `observations`,
|
||||
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||
`spc_convective_outlooks`; any other key is rejected.
|
||||
|
||||
### `promptkit`
|
||||
|
||||
@@ -173,6 +188,8 @@ key is rejected with a migration error; it is not translated or ignored.
|
||||
|
||||
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||
individual `generate`, `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 |
|
||||
| --- | --- | --- |
|
||||
@@ -186,16 +203,23 @@ individual `generate`, `run`, or `compare` command when explicitly needed.
|
||||
`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
|
||||
Tomorrow select `weather-balanced` through their exact `2.1.0` prompt
|
||||
definitions.
|
||||
|
||||
Promptkit resolves a selected profile definition from a test or embedding
|
||||
consumer's explicit in-memory profile, then the configured `profile_file` or
|
||||
`profile_dir`, then Weatherreporter's embedded catalog, and finally Promptkit's
|
||||
built-in catalog. Sources provide complete definitions; fields are never
|
||||
merged. A matching malformed external profile fails rather than using the
|
||||
embedded definition. The [Promptkit integration guide](integrations/promptkit.md)
|
||||
owns the catalog and precedence details.
|
||||
built-in catalog. Weatherreporter's embedded `weather-*` definitions are small
|
||||
aliases of Promptkit's maintained base profiles, so Promptkit also resolves
|
||||
their inherited target and settings. A configured definition with the same ID
|
||||
as either a selected profile or an inherited base takes precedence. A matching
|
||||
malformed external profile fails rather than using the embedded definition. The
|
||||
[Promptkit integration guide](integrations/promptkit.md) owns the catalog and
|
||||
precedence details.
|
||||
|
||||
An endpoint-only profile may intentionally have no backend identity. Profiles
|
||||
that require a direct API key are rejected before collection, while Promptkit
|
||||
resolves optional environment credential sources during execution.
|
||||
|
||||
To replace the default Hourly definition with a local OpenAI-compatible
|
||||
endpoint, set `profile_file` to a copy of
|
||||
@@ -213,13 +237,21 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
|
||||
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
||||
|
||||
Names remain display text, but each name must have a distinct canonical
|
||||
identity. Canonicalization trims whitespace, lowercases letters, and collapses
|
||||
punctuation and whitespace to underscores; for example, `Morning`,
|
||||
`morning!`, and `morning` conflict. Planning recognizes the canonical
|
||||
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
|
||||
their display capitalization or punctuation.
|
||||
|
||||
### `reports`
|
||||
|
||||
`reports` optionally overrides a report's ordered deterministic modules and
|
||||
Distributor path templates. Omit a report entry to retain its defaults.
|
||||
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
||||
and underscores are equivalent.
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
|
||||
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
|
||||
lookup.
|
||||
|
||||
Each report entry can contain:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
`weatherreporter.comparison.v2`. A bundle directory contains exactly these
|
||||
regular, non-symlinked files:
|
||||
|
||||
```text
|
||||
@@ -51,7 +51,7 @@ this order:
|
||||
|
||||
```text
|
||||
position, profileId, backendId, modelName, status, validationStatus,
|
||||
reportPath, error
|
||||
repairAttempts, reportPath, error
|
||||
```
|
||||
|
||||
`startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not
|
||||
@@ -66,11 +66,16 @@ contiguous from one, profile IDs are distinct and nonblank, and
|
||||
`succeeded + failed == total`.
|
||||
|
||||
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
||||
a unique Markdown `reportPath`, and no `error`. A failed result has
|
||||
and a non-negative `repairAttempts` count,
|
||||
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.
|
||||
`skipped`; it may also be `passed` when a WeatherReporter step after PromptKit
|
||||
validation failed. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
||||
`backendId` and `validationStatus` are omitted when unavailable. A failed
|
||||
result with any completed validation status must retain its non-negative
|
||||
`repairAttempts`; early operational failures omit both fields.
|
||||
|
||||
Every successful Markdown file is declared by exactly one successful result.
|
||||
The directory contains no extra entries. Consumers can therefore verify the
|
||||
@@ -81,10 +86,22 @@ larger workspace.
|
||||
|
||||
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. It rejects unknown manifest 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.
|
||||
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.
|
||||
|
||||
Only v2 is recognized as a replaceable bundle; v1 is unsupported and must be
|
||||
moved or removed before a replacement at the same destination. 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
|
||||
|
||||
@@ -8,8 +8,10 @@ and [operations guide](../../operations.md).
|
||||
|
||||
## Upload Admission
|
||||
|
||||
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
|
||||
posts a gzip-compressed source bundle to:
|
||||
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
|
||||
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
|
||||
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;
|
||||
it does not mean downstream destinations have published it.
|
||||
|
||||
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
||||
source-file mapping before calling Distributor. It reads the bearer token from
|
||||
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
|
||||
at least one source-file mapping, before calling Distributor. It reads the bearer token from
|
||||
the configured environment variable and redacts that value from errors. Request
|
||||
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
||||
|
||||
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
|
||||
|
||||
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
||||
|
||||
@@ -6,10 +6,14 @@ attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
|
||||
|
||||
## Client And Upload
|
||||
|
||||
The adapter constructs the client with the configured endpoint, bearer token,
|
||||
and an HTTP client whose timeout is the configured Distributor timeout. It
|
||||
passes no custom retry options, so the pinned client's defaults apply: three
|
||||
attempts, 100 ms base delay, and one-second maximum delay.
|
||||
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
|
||||
bearer token, and an HTTP client whose timeout is the configured Distributor
|
||||
timeout. The endpoint may include a path prefix but never userinfo, a query, or
|
||||
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:
|
||||
|
||||
@@ -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`
|
||||
status is a notification failure. A status lookup failure or a timeout before a
|
||||
terminal status remains attached to the otherwise accepted upload as diagnostic
|
||||
status information. Polling cadence, final failure handling, and redaction are
|
||||
internal behavior documented in the
|
||||
status information. Normal diagnostics use local status classifications; they
|
||||
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
|
||||
[application orchestration](../../internal/app-orchestration.md).
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Promptkit Integration
|
||||
|
||||
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are `weather.daily_generated_text`, `weather.today_generated_text`, `weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version `2.0.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
|
||||
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.1.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
|
||||
|
||||
## Logical Profile Catalog
|
||||
|
||||
Prompt definitions select a stable Weatherreporter profile ID. The embedded definitions currently use Promptkit's `openrouter` backend:
|
||||
Prompt definitions select a stable Weatherreporter profile ID. Each embedded
|
||||
definition contains only its ID and one Promptkit base-profile reference; the
|
||||
effective execution settings resolve from Promptkit's maintained catalog:
|
||||
|
||||
| Profile ID | Model | Reasoning effort | Timeout | Service tier | Default reports |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -12,39 +14,69 @@ Prompt definitions select a stable Weatherreporter profile ID. The embedded defi
|
||||
| `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.
|
||||
The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded
|
||||
profiles intentionally omit endpoints, credentials, and execution settings;
|
||||
Promptkit owns inherited resolution and its provider-native defaults.
|
||||
Promptkit's built-in `rakestrawhome-gemma-4-31b` is also available for ordinary
|
||||
and comparison selection and reports the `rakestrawhome` backend without
|
||||
Weatherreporter-specific configuration.
|
||||
|
||||
## Selection And Active Execution
|
||||
|
||||
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
|
||||
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 model; an endpoint-only profile may intentionally have no backend 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.
|
||||
A source falls through only when the selected ID is absent. Promptkit resolves a
|
||||
derived profile's base with the same source precedence, so a configured base
|
||||
can shadow a built-in base. A missing, cyclic, malformed, or incomplete
|
||||
selected inheritance chain 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.
|
||||
Profiles that require a direct API key are unsupported. Optional environment
|
||||
credential sources are Promptkit runtime concerns and are not checked by
|
||||
Weatherreporter during profile inspection. Active results retain the selected
|
||||
logical profile ID and resolved backend and model. Ordinary errors, summaries,
|
||||
logs, and outputs exclude endpoints, credentials, rendered messages, schemas,
|
||||
request bodies, response bodies, and complete parameter maps.
|
||||
|
||||
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||
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. Ordinary generation errors disclose only the safe Weatherreporter category and optional HTTP status; provider code, type, and message are written only to the explicit secure failure-debug artifact.
|
||||
|
||||
Each embedded prompt permits one Promptkit-owned corrective generation after an
|
||||
eligible failed or explicitly empty result. This is not an application retry:
|
||||
Weatherreporter performs no provider retry, profile fallback, or request-level
|
||||
output-contract override. Promptkit reports cumulative usage and the actual
|
||||
number of corrective calls; repair exhaustion remains a completed validation
|
||||
failure.
|
||||
|
||||
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 one exact prompt and every explicitly
|
||||
selected profile before weather collection. It prepares one deterministic YAML
|
||||
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 or validation failure does not
|
||||
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. The durable comparison output 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. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
|
||||
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
|
||||
|
||||
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
|
||||
endpoint path to the configured base URL path, so a service hosted under a path
|
||||
prefix must keep that prefix available. Requests use `GET` and carry the
|
||||
`weather_api.base_url` must be an absolute HTTP(S) URL. Weatherreporter joins
|
||||
each endpoint path to the configured base URL path, so a service hosted under a
|
||||
path prefix must keep that prefix available. Requests use `GET` and carry the
|
||||
configured timeout on every HTTP attempt.
|
||||
|
||||
Every request sends `format` and, except where noted below, `units`. The
|
||||
configured format must be `json`.
|
||||
|
||||
Before retrieving sources, Weatherreporter warms up
|
||||
`/conditions/current` with the same `format`, `units`, and `precision` query
|
||||
parameters used for current conditions. The warmup only requires a readable
|
||||
2xx response; its body is not decoded. Failure after its internal retry budget
|
||||
stops the fetch before source requests begin.
|
||||
Before retrieving sources, Weatherreporter requests `/conditions/current` with
|
||||
the same `format`, `units`, and `precision` query parameters used for current
|
||||
conditions. After a readable 2xx response, it retains that response for the
|
||||
normal current-conditions source step rather than making a second identical
|
||||
request. Failure after the readiness request's internal retry budget stops the
|
||||
fetch before source requests begin.
|
||||
|
||||
## Endpoints And Query Parameters
|
||||
|
||||
The adapter makes one source request for each endpoint after a successful
|
||||
warmup, subject to retry on transient failures.
|
||||
The adapter makes one source request for each endpoint, subject to retry on
|
||||
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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -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
|
||||
explicit `null` payload represents an empty alert result.
|
||||
|
||||
Hourly forecast data must be present and contain at least one `period`; a
|
||||
missing, malformed, or empty hourly product fails collection. The remaining
|
||||
sources follow the configured missing-source policy. Under `error`, collection
|
||||
fails; under `warn`, the source is omitted and an inspectable warning is
|
||||
recorded; under `none`, the source is omitted without a warning. A per-source
|
||||
policy overrides the default. See [Configuration](../config.md) for policy
|
||||
settings and [Weather data internals](../internal/weather-data.md) for recorded
|
||||
source metadata.
|
||||
Hourly forecast data must be present and contain at least one `period`. Every
|
||||
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
|
||||
after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
|
||||
product fails collection. The remaining sources follow the configured
|
||||
missing-source policy. Under `error`, collection fails; under `warn`, the
|
||||
source is omitted and an inspectable warning is recorded; under `none`, the
|
||||
source is omitted without a warning. A per-source policy overrides the default.
|
||||
See [Configuration](../config.md) for policy settings and [Weather data
|
||||
internals](../internal/weather-data.md) for recorded source metadata.
|
||||
|
||||
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||
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
|
||||
delay.
|
||||
|
||||
The adapter reads at most 10 MiB from one response body. A non-2xx response,
|
||||
request construction failure, read failure, or decode failure includes endpoint
|
||||
context in its error.
|
||||
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
|
||||
before decoding. A non-2xx response reports its relative endpoint and status,
|
||||
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
|
||||
parameters. Do not depend on a particular attempt count when implementing the
|
||||
|
||||
@@ -7,24 +7,32 @@ is owned by the [CLI reference](../cli.md) and [operations guide](../operations.
|
||||
|
||||
## Single-Report Flow
|
||||
|
||||
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
|
||||
`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. Profile inspection requires a model, permits an empty backend identity for endpoint-only profiles, and leaves inherited resolution and optional credential sources to Promptkit. The resolved profile, backend, model, and actual repair count (once a completed execution exists) are carried in the active result; the configured repair budget remains part of the exact prompt contract.
|
||||
|
||||
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Batches
|
||||
|
||||
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating prompt and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Comparisons
|
||||
|
||||
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
||||
and preflights the exact bundle destination before initializing optional prompt
|
||||
debugging, prompt inspection, or collection. It then inspects the one prompt
|
||||
and every selected profile, collects once, and delegates shared report
|
||||
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
|
||||
@@ -35,10 +43,13 @@ 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. Independent
|
||||
profile failures are recorded and do not stop peers. Context cancellation marks
|
||||
unfinished work and prevents publication. Details of prepared values, execution
|
||||
and debugging, and publication are documented in [prepared report
|
||||
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).
|
||||
@@ -47,7 +58,8 @@ 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 retains the recovery path and underlying filesystem cause.
|
||||
the returned error identifies the observed recovery state and includes a path
|
||||
only when cleanup left a sibling behind.
|
||||
|
||||
## Boundaries And Verification
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ snapshot, construct YAML, invoke Promptkit, or render a report.
|
||||
|
||||
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||
required collected and derived facts, supported report IDs, missing-data
|
||||
behavior, duplicate policy, builder, and optional prompt exporter.
|
||||
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
|
||||
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
|
||||
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
|
||||
curating prompt exports where needed. In particular, source warnings are a
|
||||
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||
missing sources, and prompt-safe SPC values omit geometry and other
|
||||
template-only or source details. The complete module composition is in
|
||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||
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
|
||||
modules are report-specific: `daily_planning` supports Daily,
|
||||
Derived daypart-summary maps use the forecast package's canonical daypart
|
||||
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.
|
||||
|
||||
## Missing data and boundaries
|
||||
|
||||
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
|
||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||
the package's severity criterion and matching discussion text exists.
|
||||
|
||||
Effective units, timezone, and location context arrive in `ModuleContext` from
|
||||
configuration and resolved report metadata. Field defaults are owned by
|
||||
[configuration](../config.md), and prompt-package layout is owned by
|
||||
[prompt input](prompt-input.md).
|
||||
`ModuleContext` carries the effective units, timezone, location context, and
|
||||
prepared identity. Report preparation creates that one `PreparedIdentity` for
|
||||
the shared report identity, timing, configuration context, and source warnings
|
||||
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
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
|
||||
|
||||
For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
|
||||
The executable derives its action context from `SIGINT` and `SIGTERM` and
|
||||
passes it to `Runner.Run`. Signal cancellation therefore uses the same action,
|
||||
summary, and error paths as other context cancellation.
|
||||
|
||||
The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned.
|
||||
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`.
|
||||
|
||||
CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
|
||||
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.
|
||||
|
||||
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
|
||||
go test ./internal/cli
|
||||
|
||||
@@ -1,43 +1,36 @@
|
||||
# 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
|
||||
[Weather API integration guide](../integrations/weatherapi.md); normalized data
|
||||
semantics belong in [weather-data internals](weather-data.md).
|
||||
[Weather API integration guide](../integrations/weatherapi.md); normalized
|
||||
source values belong in [weather-data internals](weather-data.md).
|
||||
|
||||
## Contract
|
||||
|
||||
`Run` accepts a `context.Context` and a `Request` containing effective
|
||||
`config.Config`. It constructs the Weather API adapter from that configuration,
|
||||
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
|
||||
`Run` receives a context and effective configuration in `Request`. It creates
|
||||
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
|
||||
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
|
||||
errors and fetch failures as bundle-collection errors. It does not retry,
|
||||
persist, select reports, derive facts, build modules, invoke Promptkit, or
|
||||
notify Distributor.
|
||||
The package neither chooses reports nor derives facts, builds modules, invokes
|
||||
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
|
||||
retrieval, response limits, and source-level warnings belong to the Weather
|
||||
API adapter and its integration contract.
|
||||
|
||||
## Application Composition
|
||||
## Application Use
|
||||
|
||||
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
||||
the production implementation delegates to `collect.Run`. Generation, batch
|
||||
execution, and explicit bundle fetching all use this boundary. Application
|
||||
orchestration rejects a nil collector result or a nil bundle before report work
|
||||
can continue.
|
||||
`internal/app` owns the `Collector` interface used by report workflows and
|
||||
tests. Its default implementation delegates to `collect.Run`; callers may
|
||||
substitute a collector at that boundary. Application orchestration owns
|
||||
collection timing, reuse across a workflow, and the handling of nil collection
|
||||
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
|
||||
normalized collection to planning and to every report it generates. Collection
|
||||
failure prevents later workflow work for that request.
|
||||
## Verification
|
||||
|
||||
## 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
|
||||
must not make report, period, batch, prompt, module, filesystem, or notification
|
||||
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`.
|
||||
```sh
|
||||
go test ./internal/collect
|
||||
```
|
||||
|
||||
@@ -9,9 +9,12 @@ 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 application deliberately imposes no additional semaphore:
|
||||
Promptkit owns backend capacity. Cancellation or a deadline marks unfinished
|
||||
outcomes as skipped or failed, joins work, and prevents bundle publication.
|
||||
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
|
||||
@@ -19,7 +22,9 @@ 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).
|
||||
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
|
||||
|
||||
@@ -6,24 +6,41 @@ 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 that
|
||||
authorization immediately before it writes a private sibling staging directory.
|
||||
For replacement, it moves the prior bundle to a private sibling backup,
|
||||
reauthorizes that moved entry, and restores it if installing the new bundle
|
||||
fails.
|
||||
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.
|
||||
Publication returns an inspectable cleanup error with the absolute backup path
|
||||
and underlying filesystem cause so an operator can recover or remove that
|
||||
backup manually.
|
||||
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, then
|
||||
preflights again before publication. A cancellation or any failure before the
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
the adapter reads the token from the configured environment variable and builds
|
||||
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
||||
matches the local positive timeout.
|
||||
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
|
||||
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
|
||||
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||
until the context ends. The translated `UploadResult` contains the run ID,
|
||||
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
||||
and remote error details.
|
||||
status, and `RunStatus`, including pipeline ID and lifecycle timestamps.
|
||||
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
|
||||
the caller can report an accepted-but-unconfirmed delivery. A terminal failed
|
||||
run returns that result and an error. Upload failures return no result. Upstream
|
||||
the caller can report an accepted-but-unconfirmed delivery, using a bounded
|
||||
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
|
||||
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
||||
the token.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover configuration validation, request mapping, timeouts and
|
||||
polling, status translation, conflict handling, and token redaction:
|
||||
Focused tests cover configuration validation, request mapping, response size
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
only when their valid interval overlaps the report period, with discussions
|
||||
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:
|
||||
|
||||
@@ -37,6 +42,8 @@ Report identity controls the summary shape:
|
||||
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||
The detailed grouping, daypart-window, and alert rules are owned by
|
||||
[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
|
||||
|
||||
|
||||
@@ -2,60 +2,46 @@
|
||||
|
||||
`internal/forecast` deterministically selects and summarizes normalized
|
||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||
registry dependency. Its summaries are consumed by
|
||||
[fact contracts](facts.md) and later module builders.
|
||||
registry dependency. The report-scoped caller is [fact
|
||||
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
|
||||
when both intervals share time. `BuildDailySummary` creates one local civil
|
||||
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
||||
requested period, preserving partial first and last days.
|
||||
`BuildDailySummary` builds one summary for one local civil day. The facts
|
||||
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
|
||||
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
|
||||
the shared half-open overlap rule used while selecting source values.
|
||||
|
||||
`ResolveDayparts` converts each configured name, start clock, and end clock
|
||||
into a local window. An end clock at or before its start clock wraps into the
|
||||
next civil day. The daypart and timezone defaults are defined in the
|
||||
[configuration reference](../config.md), not here.
|
||||
`ResolveDayparts` turns configured local clock ranges into windows. A range
|
||||
whose end is not after its start continues into the next civil day. The
|
||||
available daypart and timezone settings are defined in the
|
||||
[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
|
||||
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.
|
||||
## Boundaries And Failures
|
||||
|
||||
Indicators are deterministic checks over normalized values and condition text:
|
||||
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
||||
wind text are detected from the forecast description. `BuildPrecipTiming`
|
||||
sorts periods, records the maximum and first precipitation, groups contiguous
|
||||
periods at or above its package-owned probability threshold, and records
|
||||
thunder mentions.
|
||||
Daily-summary construction requires a bundle with hourly forecast data,
|
||||
valid precipitation probabilities, and valid daypart definitions. Optional
|
||||
normalized products remain absent when unavailable. Invalid alerts are ignored
|
||||
while valid overlaps are selected for the relevant day or daypart window.
|
||||
|
||||
Alert overlap parsing supports the normalized alert payload's available timing
|
||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
||||
are clipped to the requested period and ordered by alert start time.
|
||||
Thresholds, text classification, unit normalization, and alert selection are
|
||||
package implementation rules. Report identity, period selection, and the
|
||||
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.
|
||||
Direct daily or period-summary calls fail when their required bundle, valid
|
||||
period, hourly data, or daypart definitions are invalid. A nil location uses
|
||||
UTC when these APIs are called directly. Optional narrative, discussion, and
|
||||
alerts remain absent when their normalized products are absent.
|
||||
|
||||
Forecast thresholds used for brief indicators and precipitation timing are
|
||||
implementation rules.
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover local civil days, clipped periods, daypart resolution,
|
||||
summary metrics, precipitation windows, threshold helpers, and alert overlap:
|
||||
Focused `internal/forecast` tests exercise daily and overnight dayparts,
|
||||
summary derivation, invalid precipitation data, precipitation timing, and
|
||||
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
|
||||
|
||||
```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,9 +9,10 @@ maintainer-facing context fields belong to [report templates](../templates.md).
|
||||
## Catalog and validation
|
||||
|
||||
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||
generated text. `LookupDefinition` rejects unknown schema or template IDs and
|
||||
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
|
||||
value and canonical normalized JSON, loads its canonical schema through
|
||||
generated text. `LookupDefinition` requires the exact report, schema, and
|
||||
template triple and rejects unknown IDs, unsupported pairs, and a pair that
|
||||
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/reporttemplate`.
|
||||
|
||||
@@ -19,23 +20,44 @@ Daily, Today, and Tomorrow use a day-style value with required trimmed summary
|
||||
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
||||
and a single trimmed discussion string. Every form also requires the
|
||||
`precipitation_timing` field; an empty string means there is no supported timing
|
||||
prose to render. Typed decoding rejects missing required fields and unknown JSON
|
||||
fields; no general-purpose JSON Schema engine is used at runtime.
|
||||
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
|
||||
|
||||
The catalog's report-specific builders receive briefing metadata, a rich module
|
||||
snapshot, collected facts, derived facts, and the matching validated generated
|
||||
text. They decode the module stanzas needed by the template and build typed
|
||||
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
||||
metadata and periods, preserves rich module values, and uses ordered slices for
|
||||
template iteration rather than maps.
|
||||
The catalog's report-specific builders receive the prepared report identity, a
|
||||
rich module snapshot, derived facts needed to order dayparts, and the matching
|
||||
validated generated text. They require the identity's report ID to match the
|
||||
selected builder. When the optional metadata stanza is present, every shared
|
||||
identity field must agree with that prepared authority before context
|
||||
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
|
||||
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
||||
that does not match the chosen handler fail before template execution. Prompt
|
||||
packages, raw Promptkit output handling, and template asset lookup remain
|
||||
outside this package.
|
||||
Optional source stanzas become nil or fallback context fields. Today also
|
||||
computes whether its ordered dayparts contain a displayable condition so the
|
||||
template can render either rows or its explicit no-details fallback. Missing
|
||||
required stanzas, type-decoding failures, conflicting identity values, invalid
|
||||
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
|
||||
|
||||
@@ -48,5 +70,7 @@ go test ./internal/generatedtext
|
||||
```
|
||||
|
||||
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||
module and fact values. Every report definition must resolve to exactly one
|
||||
supported catalog pair.
|
||||
module and fact values. The renderer applies its plain-text policy to every
|
||||
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,6 +1,6 @@
|
||||
# Module Contract Internals
|
||||
|
||||
`internal/module` defines the stable envelope between report composition,
|
||||
`internal/module` defines the envelope between report composition,
|
||||
module builders, in-memory snapshots, templates, and prompt packages. It
|
||||
does not define a report, execute a builder, or choose prompt-export policy;
|
||||
those responsibilities belong to [report registry](report-registry.md) and
|
||||
@@ -13,8 +13,8 @@ Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
||||
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||
the template value.
|
||||
|
||||
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
|
||||
validates it. Its JSON representation contains IDs, stanza names, and rich values only;
|
||||
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
|
||||
representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
|
||||
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
||||
stanza into a caller-supplied type, reporting a missing stanza separately from
|
||||
a decoding error.
|
||||
@@ -43,7 +43,8 @@ The registry declares these ordered default compositions:
|
||||
The only non-empty default option is the AFD section selection. It accepts a
|
||||
`sections` list; omitted or empty selects all available sections. Report
|
||||
definitions may narrow it as shown above. Option shape and report compatibility
|
||||
are validated by the briefing registry.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
# Prepared Report Internals
|
||||
|
||||
`internal/app` builds a `preparedReport` after collection and before profile
|
||||
execution. This is the immutable boundary shared by ordinary report generation
|
||||
and profile comparison; it is not a durable artifact.
|
||||
`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 builds report facts, the configured module snapshot, briefing
|
||||
metadata, the curated prompt-input package, serialized YAML, and the
|
||||
generated-text definition. It deep-copies mutable facts, snapshots, metadata,
|
||||
and data-package bytes before returning them. Consumers receive independent
|
||||
copies so one execution cannot change another's input or rendering context.
|
||||
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 must also carry the prepared report's configured
|
||||
repair budget, while the completed validation records the actual corrective
|
||||
calls used within that budget. 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
|
||||
@@ -16,5 +32,7 @@ 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).
|
||||
|
||||
Preparation failure has no publication side effects. Tests for this boundary
|
||||
cover mutation isolation, byte equality, and reuse by both execution paths.
|
||||
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,27 +1,35 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends.
|
||||
`internal/promptinput` turns prepared report metadata and an ordered module
|
||||
snapshot into the YAML data package passed to Promptkit. The externally visible
|
||||
prompt and inline-input contract is owned by the [Promptkit integration
|
||||
guide](../integrations/promptkit.md); preparation of the inputs is owned by
|
||||
[prepared report internals](prepared-report.md).
|
||||
|
||||
## Package Construction
|
||||
|
||||
`Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and source warnings. Prompt input contains no historical comparison section.
|
||||
`Build` projects report identity, the report-local current date, source-warning
|
||||
summaries, and each snapshot output's prompt-facing value into a package. It
|
||||
does not expose source transport or provenance details. The module snapshot
|
||||
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 is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated prompt exports take precedence and rich values are used only as a fallback. Prompt exports are selected by the [briefing registry](briefing.md), while the rich-versus-prompt contract is in [module internals](module.md).
|
||||
`MarshalYAML` validates the package before serializing it. Serialization emits
|
||||
the metadata stanza first, then groups the remaining recognized stanzas in the
|
||||
package's fixed category order while preserving snapshot order within a
|
||||
category. `Validate` enforces the supported schema version, required report
|
||||
identity and period values, and a nonempty, complete ordered briefing.
|
||||
|
||||
## YAML Ordering And Validation
|
||||
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 stanza is placed in one category and emitted in category order while preserving its original module order:
|
||||
## Verification
|
||||
|
||||
| Category | Current stanzas |
|
||||
| --- | --- |
|
||||
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
||||
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
|
||||
| `narrative_products` | narrative forecast, discussions, and weather story |
|
||||
| `raw_data` | current conditions and hourly forecast |
|
||||
|
||||
`LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit.
|
||||
|
||||
Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves:
|
||||
Focused tests cover package construction, report-local dates, validation,
|
||||
curated snapshot exports, deterministic YAML grouping, and safe source-warning
|
||||
projection:
|
||||
|
||||
```sh
|
||||
go test ./internal/promptinput
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit. The CLI maps `promptkit` configuration to a `PromptExecutorConfig` and constructs one executor per action. Promptkit dependency types do not escape the adapter.
|
||||
|
||||
The adapter supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, the embedded fallback catalog, and its built-in catalog; the adapter does not parse profile YAML, merge sources, or probe endpoints.
|
||||
The adapter supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, embedded profile aliases and their bases, and its built-in catalog; the adapter does not parse profile YAML, resolve inheritance, merge sources, inspect optional environment credentials, or probe endpoints.
|
||||
|
||||
The adapter exposes exact prompt and profile validation plus prepared execution. It maps safe prompt identity, logical profile, effective backend/model, preparation, execution, validation, and optional debug values into `promptexec`. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
|
||||
The 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`. An empty backend identity remains valid for an endpoint-only profile; a nonblank model is required. PromptKit's configured repair-call budget and the completed result's actual corrective-call count are retained, along with its cumulative provider usage and final candidate. Structured provider generation failures become project-owned redacted generation errors that retain only bounded details through explicit accessors. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
|
||||
|
||||
The application uses the preparation callback to record active safe provenance in memory and optionally writes content-rich diagnostics only through an explicit debug writer. The adapter returns raw output for application validation and rendering. It does not retain application state, render Markdown, choose report definitions, or send Distributor notifications.
|
||||
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
# Report Registry Internals
|
||||
|
||||
`internal/report` owns report identities, valid-period resolution, exact prompt identity and version, output names, default module composition, and Distributor path declarations. Public command syntax belongs in the [CLI reference](../cli.md); configuration aliases and overrides belong in the [configuration reference](../config.md).
|
||||
`internal/report` owns the in-process registry of report identities and the
|
||||
resolution of a report's valid period. Command names and configuration aliases
|
||||
belong to the [CLI reference](../cli.md) and [configuration
|
||||
reference](../config.md), respectively.
|
||||
|
||||
## Definitions And Resolution
|
||||
## Registry And Resolution
|
||||
|
||||
Each `Definition` declares a stable ID and display name, prompt ID and version, template and generated-text schema IDs, valid-period resolver, default output name, Distributor path templates, module list, and fixed batch eligibility. `Resolved` combines a definition with one valid period and run identity.
|
||||
`DefaultRegistry` supplies the maintained definitions. `Lookup` returns a
|
||||
definition by its internal ID, while `Resolve` combines it with a request time,
|
||||
location, and optional date to produce `Resolved`. The result carries the
|
||||
definition, generation time, timezone, and resolved valid period; its metadata
|
||||
and output-name helpers keep derived identity values consistent for callers.
|
||||
|
||||
| Report ID | Prompt version | Default profile | Period policy | Fixed batch flag | Default output |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
|
||||
| `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
|
||||
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
|
||||
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
|
||||
Definitions carry the internal collaborators needed downstream: prompt and
|
||||
template identity, module configuration, output naming, Distributor path
|
||||
templates, and fixed batch eligibility. The external prompt contract is owned
|
||||
by the [Promptkit integration guide](../integrations/promptkit.md), template
|
||||
surface by the [report template guide](../templates.md), and published
|
||||
Distributor paths by the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
|
||||
Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
|
||||
`WithModuleOverrides` returns an independently cloned registry with replacement
|
||||
module configuration for recognized report IDs. The application owns batch
|
||||
planning and data-dependent inclusion; see [app orchestration
|
||||
internals](app-orchestration.md).
|
||||
|
||||
## Collaborators And Boundaries
|
||||
The registry never collects weather data, parses CLI flags, writes output,
|
||||
executes Promptkit, or delivers a report.
|
||||
|
||||
`DefaultRegistry`, `Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating report identity rules. Registry overrides clone a recognized definition and replace its module list. `DistributorPathTemplates` are consumed by app orchestration; their rendered external bundle-path contract is documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
## Verification
|
||||
|
||||
`morning` and `evening` are registry-owned batch names. Fixed flags declare Today and Tomorrow eligibility; app orchestration determines data-dependent Daily membership and the actual batch plan.
|
||||
|
||||
The registry never collects weather data, parses CLI flags, writes output, executes Promptkit, or delivers a report.
|
||||
|
||||
Focused tests cover definition completeness, command and alias lookup, period resolution, run IDs, output names, composition defaults, and override validation:
|
||||
Focused tests protect retained report definitions, period resolution, Daily
|
||||
run-ID disambiguation, and rejection of retired command or configuration names:
|
||||
|
||||
```sh
|
||||
go test ./internal/report
|
||||
|
||||
@@ -28,8 +28,13 @@ failures actionable with template or partial context.
|
||||
Top-level templates decide which shared partials they invoke. The current
|
||||
partials cover daypart forecast variants, alert digest, and precipitation
|
||||
timing. Template code receives curated typed contexts rather than raw data
|
||||
packages, and it must not reimplement weather selection or generated-text
|
||||
validation.
|
||||
packages or complete fact bundles, and it must not reimplement weather
|
||||
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
|
||||
|
||||
@@ -38,8 +43,8 @@ text, construct contexts, resolve report definitions, write state, execute
|
||||
Promptkit, or upload reports. It produces Markdown bytes for application
|
||||
orchestration to persist.
|
||||
|
||||
Focused tests cover template lookup, rendering, partial
|
||||
behavior, missing keys, and malformed context:
|
||||
Focused tests cover template lookup, rendering, partial behavior, daypart
|
||||
fallbacks, missing keys, and malformed context:
|
||||
|
||||
```sh
|
||||
go test ./internal/reporttemplate
|
||||
|
||||
@@ -31,6 +31,10 @@ provider endpoint or retry policy from the normalized types. See
|
||||
[collection](collect.md) for assembly and
|
||||
[report templates](../templates.md) for the values exposed to authors.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
|
||||
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
||||
|
||||
@@ -36,6 +36,20 @@ 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:
|
||||
@@ -55,7 +69,10 @@ 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.
|
||||
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
|
||||
@@ -64,12 +81,18 @@ 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.
|
||||
See the [configuration reference](config.md) for pipeline, bundle,
|
||||
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
|
||||
@@ -99,10 +122,18 @@ 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. 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.
|
||||
directories, symlinks, and unsafe destinations are rejected. Existing v1
|
||||
bundles are not recognized for replacement: move or remove them first.
|
||||
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
|
||||
|
||||
@@ -137,6 +168,25 @@ remain distinct. Normal output, summaries, and routine logs omit that sensitive
|
||||
content. Debug capture is never created for an ordinary command without
|
||||
`--llm-debug-dir`.
|
||||
|
||||
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.
|
||||
|
||||
Each run directory may contain `preparation.json` (v3), `execution.json` (v3),
|
||||
and, for a provider generation failure, `failure.json` (v1). The failure
|
||||
artifact retains the safe category, HTTP status, and provider code, type, and
|
||||
message for trusted debugging only. Ordinary command output never includes
|
||||
those provider details.
|
||||
|
||||
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.
|
||||
|
||||
@@ -152,10 +202,13 @@ 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, while the
|
||||
returned command error reports the retained backup path. Preserve that backup
|
||||
until it has been inspected and cleaned up manually; do not remove the new
|
||||
bundle to retry that cleanup.
|
||||
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.
|
||||
|
||||
@@ -50,10 +50,14 @@ directly.
|
||||
- Prompts receive curated module packages, never unbounded raw weather payloads.
|
||||
- Every execution validates the exact prompt version and output contract before
|
||||
collection. The selected profile is configured explicitly or declared by the
|
||||
prompt; unsupported direct-key profiles and missing reported credentials fail
|
||||
before collection.
|
||||
prompt; profiles requiring unsupported direct API keys fail before collection.
|
||||
A profile may have an empty backend identity when it supplies an endpoint;
|
||||
PromptKit resolves inherited profiles and optional credential sources when it
|
||||
executes them.
|
||||
- Prompt and profile validation completes before weather collection. Raw output
|
||||
is validated before template rendering.
|
||||
- PromptKit may make at most the prompt contract's one corrective generation;
|
||||
exhaustion is a validation rejection, not an application-level retry.
|
||||
- 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.
|
||||
@@ -62,6 +66,8 @@ directly.
|
||||
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
||||
credentials never enter normal summaries or logs. They are written only to
|
||||
an explicit secure debug root when requested.
|
||||
- Provider-controlled diagnostics never enter ordinary outputs; they are
|
||||
retained only in explicit secure failure-debug artifacts.
|
||||
|
||||
## Output, Notification, And Testing Invariants
|
||||
|
||||
@@ -72,6 +78,9 @@ directly.
|
||||
pre-publication failure, including cancellation observed immediately before
|
||||
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
|
||||
@@ -84,6 +93,8 @@ directly.
|
||||
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
|
||||
rather than live provider calls. See the [testing policy](testing.md).
|
||||
|
||||
|
||||
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.
|
||||
@@ -136,6 +136,17 @@ Any distributor enhancement should preserve the adapter boundary:
|
||||
weatherreporter selects explicit generated files and submits source bundles,
|
||||
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
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
@@ -1,398 +1,595 @@
|
||||
# LLM Profile Comparison Implementation Plan
|
||||
# PromptKit v0.8.0 Upgrade Implementation Plan
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion note: Stages 1–9 upgraded PromptKit, adopted inherited profiles and
|
||||
current credential handling, added repair and provider-failure contracts,
|
||||
enabled one corrective generation for v2.1.0 prompts, exposed repair
|
||||
provenance in ordinary and comparison results, migrated comparison bundles to
|
||||
v2, and added secure failure-debug capture.
|
||||
|
||||
## Purpose And Authority
|
||||
|
||||
This document is the ordered implementation plan for the accepted [LLM
|
||||
Profile Comparison Roadmap](profile-comparison.md). The roadmap owns the
|
||||
feature purpose, policy, scope, and desired end state. This plan records the
|
||||
completed implementation and records the corrective work completed during
|
||||
post-implementation review.
|
||||
This plan translates the accepted
|
||||
[PromptKit v0.8.0 upgrade roadmap](promptkit-v0.8.0.md) into an ordered,
|
||||
decision-complete implementation procedure. The feature roadmap owns purpose,
|
||||
scope, policy, and the desired end state. This document owns implementation
|
||||
order, concrete work allocation, stage boundaries, and verification until the
|
||||
upgrade is complete.
|
||||
|
||||
All implementation work in this plan is complete. The recorded work leaves the
|
||||
repository compiling, tested, documented to its implemented boundary, and
|
||||
internally coherent.
|
||||
The implementing agent must complete the stages in numerical order. Each stage
|
||||
is sized for one focused prompt handled by `gpt-5.6-terra` with high reasoning.
|
||||
Do not combine stages merely because adjacent work touches the same package.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
The following decisions are final for this implementation:
|
||||
|
||||
- upgrade directly from PromptKit `v0.5.0` to `v0.8.0`;
|
||||
- declare one corrective call in each embedded prompt through
|
||||
`repair_attempts: 1` rather than adding WeatherReporter repair logic;
|
||||
- keep the repair budget in the exact embedded prompt definition and add no
|
||||
global, per-report, CLI, profile, or operator configuration override;
|
||||
- advance all four exact prompt versions from `2.0.0` to `2.1.0`;
|
||||
- make `weather-light`, `weather-balanced`, and `weather-deep` minimal aliases
|
||||
of the corresponding PromptKit built-ins through `base_profile`;
|
||||
- retain the existing report-to-profile assignments and effective model
|
||||
ladder;
|
||||
- allow successfully inspected endpoint-only profiles to have an empty backend
|
||||
ID while continuing to require a nonblank model;
|
||||
- treat `APIKeyEnv` as an optional lookup source and reject only profiles that
|
||||
report `APIKeyRequired`, because WeatherReporter supplies no direct request
|
||||
credential;
|
||||
- support PromptKit's built-in `rakestrawhome-gemma-4-31b` profile without
|
||||
WeatherReporter-specific backend configuration;
|
||||
- expose provider HTTP status through a project-owned safe generation error,
|
||||
while writing provider code, type, and message only to explicit secure debug
|
||||
capture;
|
||||
- emit only `weatherreporter.comparison.v2`, with repair provenance, and do not
|
||||
preserve v1 guarded-replacement support; and
|
||||
- retain PromptKit dependency types inside the adapter and preserve all
|
||||
stateless execution, atomic publication, comparison independence, and
|
||||
disclosure invariants.
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
The following rules governed every implementation stage:
|
||||
|
||||
- Read `docs/development.md`, the task-specific documents it identifies, all
|
||||
files under `docs/policy/`, and the feature roadmap before changing code.
|
||||
- Preserve the existing `generate`, `run`, and `compare` command contracts
|
||||
except for the explicit comparison corrections defined below.
|
||||
- Keep Promptkit types and calls behind `internal/adapters/promptkit` and the
|
||||
dependency-neutral `internal/promptexec` interface.
|
||||
- Keep comparison artifacts operator-owned and explicit. They are not durable
|
||||
application state and must never be discovered or consumed implicitly by a
|
||||
later invocation.
|
||||
- Preserve comparison's prepare-once, execute-concurrently, order-results-by-
|
||||
selection, publish-on-profile-failure, and never-notify invariants.
|
||||
- Do not add a Weatherreporter concurrency limit. Promptkit owns backend
|
||||
capacity.
|
||||
- Never expose provider bodies, prompts, schemas, model output, endpoints,
|
||||
credentials, or arbitrary wrapped error text in normal JSON summaries or
|
||||
manifests.
|
||||
- Use deterministic, offline, credential-free tests. Test filesystem safety,
|
||||
concurrency, and recovery through the narrowest stable behavioral boundary;
|
||||
do not rely on timing-only sleeps or host permission behavior.
|
||||
- Run `gofmt` on changed Go files and `git diff --check` in every stage. Run
|
||||
focused tests while developing and `GOWORK=off go test -count=1 ./...` before
|
||||
completing each stage. Stages involving concurrency or filesystem mutation
|
||||
must also run affected packages with `-race`.
|
||||
- Do not commit, tag, push, or prepare a release unless the implementing prompt
|
||||
separately requests it.
|
||||
|
||||
## Completed Stages
|
||||
|
||||
### Stage 1: Comparison Artifact And Naming Contracts
|
||||
|
||||
Added the dependency-neutral comparison model, schema version, manifest
|
||||
validation and encoding, safe errors, deterministic profile filenames,
|
||||
comparison identities, default directory names, and content hashing.
|
||||
|
||||
### Stage 2: Destination Recognition And Transactional Publication
|
||||
|
||||
Added read-only destination planning, strict recognition of current comparison
|
||||
bundles, private sibling staging, guarded replacement, rollback, and atomic
|
||||
directory publication.
|
||||
|
||||
### Stage 3: Ordered Multi-Profile Preflight
|
||||
|
||||
Added exact prompt inspection followed by sequential profile inspection before
|
||||
weather collection, including effective backend, model, and credential checks.
|
||||
|
||||
### Stage 4: Immutable Shared Report Preparation
|
||||
|
||||
Extracted one immutable prepared-report value so comparison collection,
|
||||
derivation, module construction, and data-package serialization happen once.
|
||||
|
||||
### Stage 5: Profile Execution And In-Memory Rendering
|
||||
|
||||
Separated profile-specific Promptkit execution, generated-text validation, and
|
||||
Markdown rendering from output publication while preserving ordinary report
|
||||
generation behavior.
|
||||
|
||||
### Stage 6: Concurrent Ordered Profile Execution
|
||||
|
||||
Added one goroutine per selected profile using one shared executor and one
|
||||
prepared input, deterministic debug identities, isolated profile failures,
|
||||
joined cancellation, and selection-ordered results.
|
||||
|
||||
### Stage 7: Application-Level Comparison
|
||||
|
||||
Added `app.CompareDetailed`, coherent complete and partial bundle construction,
|
||||
aggregate profile-failure behavior, absolute published paths, and the
|
||||
application-level guarantee that comparison never notifies Distributor.
|
||||
|
||||
### Stage 8: Compare Command Parsing
|
||||
|
||||
Added the `compare` command request path, repeatable ordered `--profile`, exact
|
||||
`--out-dir`, guarded `--replace`, applicable common flags, validation, and one
|
||||
executor construction per invocation.
|
||||
|
||||
### Stage 9: CLI Results And Exit Behavior
|
||||
|
||||
Added structured success and failure summaries, quiet-mode suppression,
|
||||
ordered per-profile results, safe bounded errors, and nonzero exit behavior for
|
||||
partial or command-level failure.
|
||||
|
||||
### Stage 10: Canonical Documentation And Initial Validation
|
||||
|
||||
Documented the implemented CLI, operations, Promptkit integration, comparison
|
||||
bundle, application orchestration, execution, publication, architecture, and
|
||||
development contracts, then passed the original repository-wide validation
|
||||
gate.
|
||||
|
||||
## Stage 11: Make Concurrent Prompt Debug Creation Race-Safe
|
||||
|
||||
### Goal
|
||||
|
||||
Ensure concurrent comparison profiles can create their distinct debug runs
|
||||
under one new report/date directory without spuriously failing or leaving a
|
||||
test goroutine blocked.
|
||||
|
||||
### Work
|
||||
|
||||
1. Update `internal/promptdebug.ensureSecureDirectory` so concurrent creation
|
||||
of the same missing directory is idempotent. If `os.Mkdir` reports that the
|
||||
path already exists, inspect the path with `Lstat` and accept it only when it
|
||||
is the expected real directory. Continue to reject symlinks, non-directories,
|
||||
unsafe modes, and every unrelated filesystem error.
|
||||
2. Preserve the existing absolute-path, containment, `0700` directory, `0600`
|
||||
file, and no-symlink guarantees. Do not weaken debug-root validation or make
|
||||
all `EEXIST` errors successful.
|
||||
3. Add a focused prompt-debug concurrency regression that starts multiple
|
||||
writers beneath a shared missing ancestor, joins every goroutine, and
|
||||
verifies every expected artifact and permission invariant.
|
||||
4. Make comparison execution test barriers time-bounded and failure-aware. A
|
||||
callback failure before executor entry must fail the test promptly rather
|
||||
than leave `waitForProfileStarts` waiting forever.
|
||||
5. Retain distinct deterministic debug references and profile-local debug
|
||||
failure behavior.
|
||||
|
||||
### Tests And Exit Criteria
|
||||
|
||||
- The focused prompt-debug concurrency test passes repeatedly and with the race
|
||||
detector.
|
||||
- `TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences`
|
||||
cannot hang when a profile fails before reaching the fake executor.
|
||||
- Run, at minimum:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=100 ./internal/promptdebug
|
||||
GOWORK=off go test -count=100 -run TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences ./internal/app
|
||||
GOWORK=off go test -race -count=1 ./internal/promptdebug ./internal/app
|
||||
GOWORK=off go test -count=1 ./...
|
||||
```
|
||||
|
||||
## Stage 12: Make Replacement Authorization Commit-Safe
|
||||
|
||||
### Goal
|
||||
|
||||
Prevent a destination changed after the final read-only preflight from being
|
||||
treated as the previously authorized empty directory or recognized bundle and
|
||||
then deleted during replacement.
|
||||
|
||||
### Work
|
||||
|
||||
1. Replace `DestinationPlan.Exists` as the publication decision with an
|
||||
explicit destination-state classification: absent, empty real directory, or
|
||||
recognized current-schema bundle. Keep `Replace` in the plan so publication
|
||||
can apply the same authorization policy at commit time.
|
||||
2. Continue to call `PlanDestination` immediately before publication. For an
|
||||
absent target, install staging with one rename; a concurrently created
|
||||
target must cause that rename to fail without modifying the new target.
|
||||
3. For an existing target, rename that exact filesystem entry to the unique
|
||||
sibling backup before deleting or installing anything. Classify the moved
|
||||
backup while it is at its stable backup path and authorize it under the
|
||||
original replacement policy:
|
||||
|
||||
- an empty real directory is allowed with or without `Replace`;
|
||||
- a recognized current-schema comparison bundle is allowed only with
|
||||
`Replace`; and
|
||||
- a file, symlink, unrecognized/nonempty directory, unreadable entry, or
|
||||
other classification failure is not allowed.
|
||||
|
||||
4. Treat this post-move classification as the destructive-action
|
||||
authorization point. If it fails, restore the moved entry to the target and
|
||||
return an error without installing staging. If the target has concurrently
|
||||
reappeared or restoration otherwise fails, retain the backup and return an
|
||||
actionable joined or typed error that identifies its recovery path; never
|
||||
delete either entry to force restoration.
|
||||
5. Install staging only after the moved target has passed authorization. Never
|
||||
remove a backup that did not pass post-move authorization.
|
||||
6. Preserve the existing final cancellation linearization rule: cancellation
|
||||
observed before the rename transaction prevents replacement; after the
|
||||
transaction starts, finish commit or rollback rather than abandoning it.
|
||||
7. Add a package-private filesystem-operation seam only if needed for
|
||||
deterministic tests. Keep the public destination and publication APIs free
|
||||
of test-only hooks.
|
||||
|
||||
### Tests And Exit Criteria
|
||||
|
||||
- Deterministically replace an initially accepted destination after final
|
||||
preflight but before its move with each consequential unauthorized type:
|
||||
unrelated nonempty directory, regular file, and symlink. Publication must
|
||||
fail, staging must not become the target, and the moved entry must be restored
|
||||
or retained at a reported recovery path.
|
||||
- Cover an initially empty directory whose contents change before its move and
|
||||
a recognized bundle swapped for an unrecognized directory.
|
||||
- Retain coverage for absent targets, empty directories, recognized bundle
|
||||
replacement, cancellation before commit, install failure, successful
|
||||
rollback, failed rollback, and cleanup of ordinary staging failures.
|
||||
- Run `GOWORK=off go test -race -count=1 ./internal/comparison` and the
|
||||
repository-wide standard test command.
|
||||
|
||||
## Stage 13: Represent Committed Publication Cleanup Failures Accurately
|
||||
|
||||
### Goal
|
||||
|
||||
Keep application and CLI results truthful when the new comparison bundle has
|
||||
been committed but removal of the old sibling backup fails.
|
||||
|
||||
### Work
|
||||
|
||||
1. Change comparison publication to return a dependency-neutral result as well
|
||||
as an error:
|
||||
|
||||
```go
|
||||
type PublicationResult struct {
|
||||
Committed bool
|
||||
RetainedBackupPath string
|
||||
}
|
||||
|
||||
func Publish(
|
||||
ctx context.Context,
|
||||
plan DestinationPlan,
|
||||
bundle LogicalBundle,
|
||||
) (PublicationResult, error)
|
||||
```
|
||||
|
||||
2. Define `Committed` as meaning the complete staged bundle is now installed
|
||||
at the target. Pre-commit, staging, authorization, install, and successful-
|
||||
rollback failures return `Committed == false`. A successful install returns
|
||||
`Committed == true` even if later backup cleanup fails.
|
||||
3. Add a typed post-commit cleanup error that unwraps its filesystem cause and
|
||||
records the retained backup path for operator recovery. On this error,
|
||||
return `Committed == true` and the absolute retained backup path. Do not
|
||||
roll back or remove the newly committed valid bundle merely because old
|
||||
backup cleanup failed.
|
||||
4. In `app.CompareDetailed`, populate `ManifestPath`, `DataPackagePath`, and
|
||||
successful profile `ReportPath` values whenever publication reports
|
||||
`Committed == true`, before returning any cleanup error.
|
||||
5. Treat post-commit cleanup failure as a command-level operational failure:
|
||||
return the non-nil structured result plus an error, produce status `failed`,
|
||||
and exit nonzero even though the published artifact paths are present. The
|
||||
ordinary safe JSON error must not contain the raw filesystem cause or backup
|
||||
path; the wrapped diagnostic returned on stderr may identify the retained
|
||||
backup for recovery.
|
||||
6. Keep `RetainedBackupPath` out of the versioned comparison manifest. It
|
||||
describes an incomplete local transaction cleanup, not the logical bundle.
|
||||
|
||||
### Tests And Exit Criteria
|
||||
|
||||
- Inject a deterministic backup-removal failure after successful installation
|
||||
and assert the target is the new recognized bundle, the old bundle remains
|
||||
at the reported backup, `Committed` is true, and the error is inspectable by
|
||||
type.
|
||||
- At the application boundary, assert all committed artifact paths are
|
||||
absolute and populated while the method still returns an error.
|
||||
- At the CLI boundary, assert status `failed`, nonzero return, present artifact
|
||||
paths, and a bounded generic safe error with no raw filesystem detail.
|
||||
- Retain tests showing every pre-commit or rolled-back failure omits published
|
||||
artifact paths.
|
||||
- Run comparison, application, and CLI tests with `-race`, then the
|
||||
repository-wide standard test command.
|
||||
|
||||
## Stage 14: Complete Structured Failure Metadata And Classification
|
||||
|
||||
### Goal
|
||||
|
||||
Make every non-nil comparison result a reliable description of the attempted
|
||||
run and preserve useful safe error categories in the top-level CLI summary.
|
||||
|
||||
### Work
|
||||
|
||||
1. In `app.CompareDetailed`, assign the absolute resolved
|
||||
`OutputDirectory` immediately after output-directory resolution and before
|
||||
destination preflight. Do not wait for `PlanDestination` to succeed.
|
||||
2. Once the initial `ComparisonResult` exists, guarantee that every return path
|
||||
sets a nonzero UTC `FinishedAt` that is not before `StartedAt`. Use one
|
||||
centralized finalization path or a defer; do not scatter timestamp writes
|
||||
across individual failures.
|
||||
3. Continue to omit manifest, data-package, and report paths until publication
|
||||
commits. Preserve whatever prompt identity fields have actually been
|
||||
resolved; never invent a hash or profile result for a phase that did not
|
||||
run.
|
||||
4. Update `safeComparisonSummaryError` to use this stable mapping, always
|
||||
passing messages through `comparison.NewSafeError`:
|
||||
|
||||
| Error | Category | Safe message |
|
||||
| --- | --- | --- |
|
||||
| aggregate profile failure | `application` | existing bounded aggregate message |
|
||||
| `context.Canceled` | `canceled` | `comparison canceled` |
|
||||
| `context.DeadlineExceeded` | `deadline_exceeded` | `comparison deadline exceeded` |
|
||||
| categorized `promptexec` error | exact `promptexec.CategoryOf` value | `comparison prompt operation failed` |
|
||||
| `comparison.DestinationError` | `destination_<kind>` | `comparison destination preflight failed` |
|
||||
| post-commit cleanup error | `publication_cleanup` | `comparison published but cleanup did not complete` |
|
||||
| any unknown error | `application` | `comparison did not complete` |
|
||||
|
||||
5. Apply the most specific mapping before a more general wrapped match. In
|
||||
particular, detect the post-commit cleanup and destination types before
|
||||
falling back to a nested filesystem or context cause.
|
||||
6. Do not copy `DestinationError.Target`, wrapped causes, or arbitrary
|
||||
`error.Error()` text into normal JSON. Detailed returned errors remain
|
||||
available on stderr and through Go error inspection.
|
||||
|
||||
### Tests And Exit Criteria
|
||||
|
||||
- Add application tests for destination-preflight, debug initialization,
|
||||
prompt-preflight, collection, and preparation failures. Whenever a non-nil
|
||||
result is returned, assert an absolute output directory, nonzero ordered UTC
|
||||
timestamps, and omission of unpublished artifact paths.
|
||||
- Add table-driven CLI tests for every mapping row, including wrapped errors,
|
||||
and assert that unsafe sentinel text cannot enter serialized output.
|
||||
- Preserve existing ordered profile-level categories and safe messages.
|
||||
- Run application and CLI tests with `-race`, then the repository-wide standard
|
||||
test command.
|
||||
|
||||
## Stage 15: Remove Temporary Seams, Reconcile Documentation, And Validate
|
||||
|
||||
### Goal
|
||||
|
||||
Remove review-discovered maintenance debt, document the corrected implemented
|
||||
behavior in its canonical owners, and complete the release-equivalent gate.
|
||||
|
||||
### Work
|
||||
|
||||
1. Remove the unused `Runner.resolveComparison` wrapper. Remove the
|
||||
test-oriented `Runner.executeComparison` seam if it has no production
|
||||
caller, and rewrite its remaining coverage through `Runner.Run`,
|
||||
`resolveComparisonAction`, or another stable behavioral boundary.
|
||||
2. Remove `comparisonProfileOutcome.err` if production code still does not use
|
||||
it. Keep raw failures in returned/wrapped errors or explicit internal error
|
||||
types; do not retain an otherwise dead field solely for private test
|
||||
assertions.
|
||||
3. Correct the `internal/comparison` package comment so it describes the
|
||||
package's actual ownership of both logical comparison contracts and
|
||||
filesystem destination/publication behavior.
|
||||
4. Update only the canonical current-state documents affected by Stages 11
|
||||
through 14:
|
||||
|
||||
- `docs/internal/comparison-publication.md` owns post-move authorization,
|
||||
commit state, rollback, retained backups, and cleanup mechanics;
|
||||
- `docs/internal/comparison-execution.md` owns concurrency and debug-write
|
||||
behavior;
|
||||
- `docs/internal/app-orchestration.md` owns partial results and committed
|
||||
publication error handling;
|
||||
- `docs/cli.md` owns structured status, safe category, path, and exit
|
||||
behavior; and
|
||||
- `docs/operations.md` owns operator recovery for a retained sibling backup.
|
||||
|
||||
Link rather than duplicating complete contracts, and update architecture or
|
||||
integration documentation only if its existing invariant is inaccurate.
|
||||
5. Mark this plan `Complete` and restore the feature roadmap's implemented
|
||||
status after every exit criterion below passes. Retain or remove the two
|
||||
roadmap documents only according to a later maintainer-directed roadmap
|
||||
cleanup; do not archive them as a second current-state reference in this
|
||||
stage.
|
||||
|
||||
### Tests And Exit Criteria
|
||||
|
||||
- Confirm no production-only helper or field remains solely to support tests,
|
||||
and no test loses meaningful behavioral coverage during cleanup.
|
||||
- Verify changed relative links and fenced examples. Search current-state docs
|
||||
for stale claims about comparison publication, debug behavior, results, or
|
||||
recovery.
|
||||
- Run the release-equivalent local gate:
|
||||
|
||||
```sh
|
||||
set -eu
|
||||
test -z "$(git ls-files go.work go.work.sum)"
|
||||
test ! -e vendor
|
||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod; then
|
||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||
exit 1
|
||||
fi
|
||||
GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -race -count=1 ./...
|
||||
GOWORK=off go vet ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go mod tidy -diff
|
||||
unformatted="$(git ls-files '*.go' | while IFS= read -r file; do gofmt -l "$file"; done)"
|
||||
test -z "$unformatted"
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- Run `GOWORK=off go run ./cmd/weatherreporter --help` and
|
||||
`GOWORK=off go run ./cmd/weatherreporter compare --help` without credentials
|
||||
or network access, and confirm that help agrees with `docs/cli.md`.
|
||||
- Inspect the final diff for accidental generated artifacts, secrets,
|
||||
workspaces, vendored dependencies, release notes, or unrelated changes.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The roadmap and the contracts in Stages 11 through 15 define the
|
||||
remaining decisions needed to complete the corrective work.
|
||||
For every stage:
|
||||
|
||||
- read `docs/development.md`, all files under `docs/policy/`, this plan, the
|
||||
feature roadmap, and the task-specific documents named by the stage;
|
||||
- inspect the current code and tests before editing; use the repository's code
|
||||
knowledge graph first for code discovery and fall back to text search for
|
||||
literals, assets, and documentation;
|
||||
- implement only the stage's scope and preserve unrelated user changes;
|
||||
- keep PromptKit/provider types, client construction, YAML parsing, repair
|
||||
mechanics, and provider transport inside the existing adapter boundary;
|
||||
- use deterministic, offline, credential-free tests and injected clients or
|
||||
synthetic fixtures rather than live OpenRouter, Rakestrawhome, or local
|
||||
endpoint calls;
|
||||
- add tests at the narrowest stable owner identified by the testing policy and
|
||||
avoid copying PromptKit's internal test matrices;
|
||||
- update the canonical documentation owners listed for that stage in the same
|
||||
change as the implemented contract;
|
||||
- run `gofmt` on changed Go files, the stage's focused tests,
|
||||
`GOWORK=off go test -count=1 ./...`, and `git diff --check`; and
|
||||
- leave the repository passing before proceeding to the next stage.
|
||||
|
||||
Stages affecting concurrent comparison, cancellation, or secure debug
|
||||
filesystem work must also run the named focused packages with `-race`. Do not
|
||||
weaken an existing assertion solely to accommodate the new dependency. When a
|
||||
test encodes an intentionally changed contract, replace it with a behavioral
|
||||
assertion for the accepted policy.
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
### Stage 1: Upgrade The Dependency And Establish A v0.8.0 Baseline
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: move to the tagged dependency and isolate compatibility changes before
|
||||
adopting new WeatherReporter behavior.
|
||||
|
||||
Work:
|
||||
|
||||
1. Update `go.mod` to require
|
||||
`gitea.maximumdirect.net/eric/promptkit v0.8.0` and refresh `go.sum` with
|
||||
`GOWORK=off go mod tidy`. Do not add `go.work`, `vendor`, or a `replace`
|
||||
directive and do not change WeatherReporter's Go version.
|
||||
2. Resolve any compile failures using PromptKit's public root package only.
|
||||
Keep all `Profile` and `OpenAICompatibleProfileConfig` literals keyed. Do not
|
||||
register the now-reserved `rakestrawhome` backend.
|
||||
3. Reconcile adapter tests that directly exercise PromptKit's changed optional
|
||||
credential behavior. A profile whose only credential metadata is
|
||||
`api_key_env` must reach an injected client when the environment value is
|
||||
absent; it must no longer expect PromptKit to return
|
||||
`ErrAPIKeyEnvMissing`. Do not change WeatherReporter's application preflight
|
||||
in this stage.
|
||||
4. Verify that every current embedded prompt, content file, schema, and fallback
|
||||
profile inspects under v0.8.0. Verify selected invalid local endpoints and
|
||||
malformed selected profile definitions still map to project-owned
|
||||
configuration or profile-load categories.
|
||||
5. Review the v0.6.0 compatibility corrections against supported
|
||||
WeatherReporter inputs: metadata-authoritative identities, exact contained
|
||||
`content_file` paths, regular embedded files, structurally valid endpoints,
|
||||
bounded JSON-compatible values, cancellation identity, and strict response
|
||||
framing. Add consumer tests only for a WeatherReporter boundary not already
|
||||
protected by PromptKit.
|
||||
|
||||
Do not enable profile inheritance or output repair yet. The expected result is
|
||||
the current WeatherReporter feature set running against PromptKit v0.8.0.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/adapters/promptkit ./internal/promptassets
|
||||
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit
|
||||
```
|
||||
|
||||
### Stage 2: Adopt Profile Inheritance And Current Credential Routing
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: adopt v0.7.0 profile composition, endpoint-only routing, optional
|
||||
credential semantics, and the Rakestrawhome built-in without changing the model
|
||||
ladder.
|
||||
|
||||
Work:
|
||||
|
||||
1. Replace the three embedded profile bodies with these exact leaf/base
|
||||
relationships and no duplicated execution settings:
|
||||
|
||||
| Leaf | Base |
|
||||
| --- | --- |
|
||||
| `weather-light` | `deepseek-4-flash` |
|
||||
| `weather-balanced` | `gemini-flash-latest` |
|
||||
| `weather-deep` | `claude-sonnet-latest` |
|
||||
|
||||
2. Update prompt-asset fixtures and tests to understand `base_profile`. Assert
|
||||
that all three leaf IDs remain selected identities and resolve to the same
|
||||
backend, model, timeout, service tier, and reasoning settings exposed by the
|
||||
current standalone definitions. Test relationships and effective behavior,
|
||||
not copied private constants beyond the intentional model-ladder contract.
|
||||
3. Preserve source precedence. Cover a standalone same-ID operator override, a
|
||||
derived operator override, a configured source that shadows a base ID, and
|
||||
selected missing-base, cyclic, malformed-base, and incomplete-target
|
||||
failures. Do not implement inheritance or merging in WeatherReporter; all
|
||||
resolution must remain PromptKit-owned.
|
||||
4. Change application profile preflight to accept a successful inspection with
|
||||
a nonblank model and an empty backend ID. Trust PromptKit inspection to have
|
||||
resolved either a backend or endpoint; do not add the endpoint to
|
||||
`promptexec.ProfileInspection` or ordinary provenance.
|
||||
5. Remove application-level environment lookup and rejection for a nonblank
|
||||
`APIKeyEnv`. Remove the now-unused `LookupEnv` fields and plumbing from
|
||||
prompt, batch, and comparison inspection requests. Continue rejecting
|
||||
`CredentialRequired`/`APIKeyRequired` before weather collection with the
|
||||
existing missing-credential category.
|
||||
6. Add an end-to-end offline regression proving the maintained endpoint-only
|
||||
`weather-light` example passes application inspection, retains an empty
|
||||
backend ID, and does not expose its endpoint.
|
||||
7. Prove `rakestrawhome-gemma-4-31b` can pass ordinary and comparison profile
|
||||
inspection through the existing adapter and reports the PromptKit
|
||||
`rakestrawhome` backend ID. Do not make a provider call or add
|
||||
Rakestrawhome-specific configuration.
|
||||
|
||||
Canonical documentation in this stage:
|
||||
|
||||
- update `docs/policy/architecture.md` so only direct-key-required profiles
|
||||
fail credential preflight and backend identity is optional for endpoint-only
|
||||
profiles;
|
||||
- update `docs/config.md` to distinguish same-ID source replacement from
|
||||
`base_profile` chain inheritance and to describe optional environment
|
||||
credentials;
|
||||
- update `docs/integrations/promptkit.md` for profile composition, parent
|
||||
lookup precedence, endpoint-only identity, optional credentials, and
|
||||
Rakestrawhome availability; and
|
||||
- update `docs/internal/promptkit-adapter.md` and focused app internals for the
|
||||
implemented inspection behavior.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/promptassets ./internal/adapters/promptkit ./internal/app
|
||||
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit ./internal/app
|
||||
```
|
||||
|
||||
### Stage 3: Extend The Project-Owned Prompt Execution Contract
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: establish dependency-neutral repair and structured-generation-error
|
||||
values before the adapter or application relies on them.
|
||||
|
||||
Work:
|
||||
|
||||
1. Add `RepairAttempts int` to `promptexec.OutputContract`. It is the configured
|
||||
additional-call budget from the exact prompt contract.
|
||||
2. Add `RepairAttempts int` to `promptexec.Validation`. It is the number of
|
||||
corrective calls actually started for the completed result. Update
|
||||
`NewValidation` and every caller so construction is explicit; reject or
|
||||
normalize no values here because PromptKit owns output-contract validity.
|
||||
3. Update all copy helpers, equality/provenance helpers, fixtures, and tests so
|
||||
repair values are retained without sharing mutable state.
|
||||
4. Add a project-owned immutable `promptexec.GenerationError` with unexported
|
||||
status and provider-detail fields plus safe accessors:
|
||||
|
||||
- `StatusCode() int`
|
||||
- `ProviderCode() string`
|
||||
- `ProviderType() string`
|
||||
- `ProviderMessage() string`
|
||||
- `Category() ErrorCategory`, always returning `Generation`
|
||||
- `Error()`, exposing only the WeatherReporter generation category/message
|
||||
and optional HTTP status
|
||||
- `GoString()`, returning the same safe representation
|
||||
- `Unwrap()`, preserving a project-owned `*promptexec.Error`
|
||||
|
||||
5. Provide one constructor used by adapters. Defensively normalize valid UTF-8
|
||||
and bound code/type to 256 Unicode code points and message to 4,096 Unicode
|
||||
code points, even though PromptKit already bounds its accessors. Do not
|
||||
expose fields through struct formatting, JSON tags, or exported mutable
|
||||
fields. Preserve the dependency cause only behind the project-owned error so
|
||||
`errors.Is`/`errors.As` identities remain available without entering error
|
||||
text.
|
||||
6. Add focused tests proving nil/zero safety, category and unwrap behavior,
|
||||
status-only ordinary formatting, `%#v` redaction, provider-detail bounds,
|
||||
and repair-value copying.
|
||||
|
||||
Do not import PromptKit from `internal/promptexec` and do not change CLI or
|
||||
artifact schemas in this stage.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/promptexec
|
||||
```
|
||||
|
||||
### Stage 4: Map PromptKit v0.8.0 Repair And Generation Errors In The Adapter
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: make the adapter faithfully translate v0.8.0 preparation, execution,
|
||||
validation, usage, and failure values into the Stage 3 contract.
|
||||
|
||||
Work:
|
||||
|
||||
1. Map `promptkit.OutputContract.RepairAttempts` in prompt inspection and
|
||||
prepared-execution details. Map
|
||||
`promptkit.ValidationResult.RepairAttempts` in completed execution.
|
||||
2. Preserve PromptKit's cumulative usage exactly as reported across the initial
|
||||
call and every completed correction. Continue returning only the final raw
|
||||
candidate and final validation result, subject to WeatherReporter's 64 KiB
|
||||
generated-output bound.
|
||||
3. In adapter error classification, retain cancellation, deadline, and capacity
|
||||
precedence. Before the generic `ErrLLMGenerate` branch, use `errors.As` for
|
||||
`*promptkit.GenerationError` and construct the project-owned
|
||||
`promptexec.GenerationError` with status, code, type, message, and hidden
|
||||
cause. Initial and corrective generation failures use the same mapping.
|
||||
4. Extend the injected adapter client used by tests so it can return an ordered
|
||||
sequence of responses or errors and record each request safely.
|
||||
5. Use a synthetic PromptKit prompt with JSON Schema validation and
|
||||
`repair_attempts: 1` to cover:
|
||||
|
||||
- first-pass valid output with zero corrections;
|
||||
- explicitly empty or invalid output followed by valid corrected output;
|
||||
- one-attempt exhaustion returning a final failed validation result rather
|
||||
than an operational error;
|
||||
- a non-2xx-style `GenerationError` during correction;
|
||||
- cumulative token usage and actual repair count; and
|
||||
- the same prepared prompt/profile identity across the corrective flow.
|
||||
|
||||
6. Keep these tests at the adapter boundary. Do not assert PromptKit's private
|
||||
corrective-message wording or reconstruct its internal repair algorithm.
|
||||
|
||||
Canonical documentation in this stage: update
|
||||
`docs/internal/promptkit-adapter.md` for the repair/result/error mappings. Do
|
||||
not yet claim that embedded WeatherReporter prompts enable repair.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/adapters/promptkit ./internal/promptexec
|
||||
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit
|
||||
```
|
||||
|
||||
### Stage 5: Carry Repair Provenance Through Application Workflows
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: make application orchestration understand configured and actual repair
|
||||
counts before changing the embedded prompt policy.
|
||||
|
||||
Work:
|
||||
|
||||
1. Add an expected generated-text repair budget to `report.Definition` and set
|
||||
it explicitly to zero for all four current `2.0.0` definitions in this
|
||||
stage. Include it in report-definition validation and retained contract
|
||||
tests.
|
||||
2. Extend exact prompt preflight so format, validation mode, schema path, and
|
||||
repair budget must all match the resolved report definition. Extend
|
||||
preparation and completion provenance checks to require the same repair
|
||||
budget across inspection and the opaque prepared snapshot.
|
||||
3. Add `RepairAttempts *int` to application outcomes where execution may fail
|
||||
before validation exists. Set it to a fresh pointer immediately after a
|
||||
non-nil completed execution is returned, before WeatherReporter's secondary
|
||||
generated-text validation. A pointer is required so completed first-pass
|
||||
zero is distinguishable from unavailable provenance.
|
||||
4. Carry independent copies through `ReportResult`, `BatchReportResult`, batch
|
||||
conversion, comparison execution's internal outcome, and relevant test
|
||||
fakes. Do not expose the new value in CLI or comparison JSON yet.
|
||||
5. Preserve the actual count on PromptKit validation rejection and on later
|
||||
WeatherReporter generated-text or render failures. Leave it unavailable on
|
||||
preparation, capacity, cancellation, deadline, and generation errors that
|
||||
return no completed PromptKit result.
|
||||
6. Add focused tests for provenance mismatch, completed zero, completed
|
||||
positive, validation rejection, later local validation failure, early
|
||||
operational failure, batch copying, and independent concurrent profile
|
||||
outcomes.
|
||||
|
||||
Canonical documentation in this stage: update the focused prepared-report and
|
||||
app-orchestration internals to describe configured versus actual repair
|
||||
provenance. Current public documents should continue to report the embedded
|
||||
budget as zero until Stage 6.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/report ./internal/app ./internal/cli
|
||||
GOWORK=off go test -race -count=1 ./internal/app
|
||||
```
|
||||
|
||||
### Stage 6: Activate One Repair And Expose Ordinary Result Provenance
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: switch the operational prompts to the accepted one-correction policy
|
||||
and make ordinary generate/run/batch output report what occurred.
|
||||
|
||||
Work:
|
||||
|
||||
1. Add `repair_attempts: 1` to the output contract of all four embedded prompt
|
||||
definitions and change each exact prompt version from `2.0.0` to `2.1.0`.
|
||||
Do not change prompt text or generated-text schemas solely for this upgrade.
|
||||
2. Change all four report registry definitions to exact prompt version `2.1.0`
|
||||
and expected repair budget one. Update exact-version fixtures and assertions
|
||||
throughout adapter, app, CLI, report, and prompt-asset tests. Remove tests
|
||||
that classify `repair_attempts` as a retired setting and replace them with
|
||||
an exact one-attempt contract assertion.
|
||||
3. Add `repairAttempts` to successful and failed generate and batch JSON result
|
||||
shapes through the Stage 5 pointers. Emit integer zero for a completed
|
||||
first-pass result, a positive integer for a completed repaired result, and
|
||||
omit the field when no completed validation made it available.
|
||||
4. Keep the existing `validationStatus` and failure categories authoritative.
|
||||
A repaired valid result proceeds normally. Repair exhaustion remains
|
||||
`validation_rejected`, publishes no report for that profile, and retains the
|
||||
actual attempt count.
|
||||
5. Add representative offline assembled tests proving first-pass success,
|
||||
repaired success, exhaustion, explicit empty initial content, and batch
|
||||
result propagation. Reuse the real PromptKit adapter with an injected
|
||||
sequence client for at least one end-to-end repaired execution; use the
|
||||
existing app fake at other boundaries where lower-level repair is already
|
||||
covered.
|
||||
6. Confirm no application loop, provider retry, profile fallback, or
|
||||
request-level `OutputContract` override was introduced.
|
||||
|
||||
Canonical documentation in this stage:
|
||||
|
||||
- update `docs/policy/architecture.md` with PromptKit-owned bounded repair and
|
||||
failed-exhaustion invariants;
|
||||
- update `docs/integrations/promptkit.md` with exact prompt version `2.1.0`, one
|
||||
configured repair, actual-count semantics, cumulative usage, explicit-empty
|
||||
handling, and the distinction from operational retries;
|
||||
- update `docs/cli.md` for generate and batch `repairAttempts` fields;
|
||||
- update `docs/internal/report-registry.md`, prepared-report internals, and app
|
||||
orchestration internals for exact version and repair flow; and
|
||||
- keep configuration documentation unchanged because no repair setting is
|
||||
added.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/promptassets ./internal/report ./internal/adapters/promptkit ./internal/app ./internal/cli
|
||||
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit ./internal/app
|
||||
```
|
||||
|
||||
### Stage 7: Migrate Comparison Bundles To v2
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: preserve repair activity in the profile-evaluation artifact and make
|
||||
the strict durable schema change explicit.
|
||||
|
||||
Work:
|
||||
|
||||
1. Change `comparison.SchemaVersion` to
|
||||
`weatherreporter.comparison.v2`. Emit and recognize v2 only; do not retain a
|
||||
v1 parser or guarded-replacement compatibility path.
|
||||
2. Add `RepairAttempts *int` to each application comparison profile result,
|
||||
CLI comparison profile summary, and durable `comparison.Result`. Propagate a
|
||||
fresh copy from Stage 5's execution outcome.
|
||||
3. Place `repairAttempts` immediately after `validationStatus` in the canonical
|
||||
result-object JSON field order. Encode zero for completed first-pass
|
||||
validation, a positive integer for completed correction, and omit it only
|
||||
when no completed validation exists.
|
||||
4. Tighten manifest invariants: every non-nil repair count is non-negative; a
|
||||
successful result must have `validationStatus: "passed"` and a non-nil
|
||||
repair count; a failed result with a completed validation status must also
|
||||
have a non-nil count; and an early operational failure may omit both.
|
||||
5. Update the strict token-level JSON recognizer to accept only the canonical
|
||||
`repairAttempts` field at its correct object level, reject duplicate,
|
||||
unknown, negative, fractional, string, overflow, and malformed values, and
|
||||
continue rejecting v1 as an unsupported current bundle.
|
||||
6. Update manifest construction, cloning, validation, exact serialization
|
||||
tests, guarded replacement tests, malicious bundle tests, partial-success
|
||||
tests, and CLI comparison summaries. Preserve flat layout, result ordering,
|
||||
hashes, atomic publication, cancellation safety, and no Distributor calls.
|
||||
7. Cover concurrent peers where one succeeds first-pass, one repairs, one
|
||||
exhausts, and one fails operationally. The counts must remain attached to
|
||||
the selected profile positions without races or cross-contamination.
|
||||
|
||||
Canonical documentation in this stage:
|
||||
|
||||
- replace the v1 contract in `docs/integrations/comparison-bundle.md` with v2,
|
||||
including exact field order, presence rules, and the lack of v1 replacement
|
||||
compatibility;
|
||||
- update `docs/cli.md` for comparison `repairAttempts`;
|
||||
- update `docs/operations.md` to tell operators to move or remove an existing
|
||||
v1 bundle before replacing at the same destination; and
|
||||
- update comparison execution/publication internals and architecture policy as
|
||||
needed for the current-only version invariant.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/comparison ./internal/app ./internal/cli
|
||||
GOWORK=off go test -race -count=1 ./internal/comparison ./internal/app
|
||||
```
|
||||
|
||||
### Stage 8: Add Secure Provider-Failure Debug Capture
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: expose useful PromptKit v0.7.0 provider diagnostics only through the
|
||||
existing explicit secure debug boundary while keeping ordinary errors safe.
|
||||
|
||||
Work:
|
||||
|
||||
1. Extend prompt preparation debug output with configured
|
||||
`repairAttempts` and advance its schema identifier from
|
||||
`weatherreporter.prompt_preparation_debug.v2` to
|
||||
`weatherreporter.prompt_preparation_debug.v3`.
|
||||
2. Extend execution validation debug output with actual `repairAttempts` and
|
||||
advance its schema identifier from
|
||||
`weatherreporter.prompt_execution_debug.v2` to
|
||||
`weatherreporter.prompt_execution_debug.v3`. Retain cumulative token usage.
|
||||
3. Add a dedicated `failure.json` artifact with schema identifier
|
||||
`weatherreporter.prompt_failure_debug.v1`. Its canonical fields are:
|
||||
|
||||
- top level: `schemaVersion`, `reportId`, `validDate`, `runId`, `failure`;
|
||||
- failure object: `category`, `statusCode`, `providerCode`, `providerType`,
|
||||
`providerMessage`;
|
||||
- omit absent provider fields and zero status; and
|
||||
- never include the raw provider body, headers, endpoint, credentials,
|
||||
request, schema, rendered prompt, or generated candidate.
|
||||
|
||||
4. Add `PromptDebugWriter.WriteFailure` using the existing handle-relative
|
||||
secure run directory, `0700` directory and `0600` file modes, canonical JSON
|
||||
encoding, and no-follow/atomic replacement behavior. Disabled writers must
|
||||
perform no filesystem work.
|
||||
5. When execution returns an error, use `errors.As` only against the
|
||||
project-owned `*promptexec.GenerationError`. If explicit debug capture is
|
||||
enabled, write `failure.json` using that profile's existing debug reference.
|
||||
This applies equally to initial and corrective provider failures and keeps
|
||||
comparison profile directories isolated.
|
||||
6. If failure-debug writing also fails, retain the generation failure as the
|
||||
primary categorized error and join the safe debug-write failure rather than
|
||||
replacing or hiding the provider failure. Never place provider code, type,
|
||||
or message in the joined error text.
|
||||
7. Ordinary generate, batch, and comparison errors should gain only the safe
|
||||
HTTP status already rendered by `promptexec.GenerationError.Error`; do not
|
||||
add provider detail fields to CLI summaries, comparison manifests, logs, or
|
||||
Distributor requests.
|
||||
8. Add adversarial tests for formatter redaction, malicious provider strings,
|
||||
JSON escaping, bounds, absent fields, file modes, symlink/path attacks,
|
||||
write failure, cancellation identity, initial versus corrective failures,
|
||||
and concurrent comparison captures.
|
||||
|
||||
Canonical documentation in this stage:
|
||||
|
||||
- update `docs/operations.md` with the three debug artifact versions,
|
||||
`failure.json`, sensitivity, permissions, and retention;
|
||||
- update `docs/integrations/promptkit.md` with ordinary status-only disclosure
|
||||
and debug-only provider detail;
|
||||
- update prompt-debug, PromptKit-adapter, and app-orchestration internals; and
|
||||
- ensure `docs/policy/architecture.md` explicitly prohibits provider-controlled
|
||||
diagnostics from ordinary outputs.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
GOWORK=off go test -count=1 ./internal/promptexec ./internal/promptdebug ./internal/adapters/promptkit ./internal/app ./internal/cli
|
||||
GOWORK=off go test -race -count=1 ./internal/promptdebug ./internal/adapters/promptkit ./internal/app
|
||||
```
|
||||
|
||||
### Stage 9: Reconcile Documentation And Perform The Final Upgrade Audit
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Purpose: verify the complete end state as one coherent WeatherReporter feature
|
||||
and leave no stale v0.5.0, prompt v2.0.0, comparison v1, credential, profile,
|
||||
repair, or debug claims.
|
||||
|
||||
Work:
|
||||
|
||||
1. Re-read the feature roadmap, `docs/development.md`, every policy document,
|
||||
and every canonical document changed by Stages 1-8. Reconcile them against
|
||||
executable behavior and remove duplicated or stale definitions. Keep
|
||||
unimplemented future ideas in `docs/roadmap/future.md`, not current-state
|
||||
documents.
|
||||
2. Search code, embedded assets, examples, tests, and documentation for stale
|
||||
contractual literals and review every occurrence of:
|
||||
|
||||
- PromptKit `v0.5.0`, `v0.6.0`, and `v0.7.0` as an active dependency claim;
|
||||
- prompt version `2.0.0`;
|
||||
- `weatherreporter.comparison.v1`;
|
||||
- prompt debug schema v2 identifiers;
|
||||
- claims that profile fields never inherit;
|
||||
- claims that every `APIKeyEnv` must be populated;
|
||||
- claims that backend ID is always required;
|
||||
- claims that repair is disabled or `repair_attempts` is retired; and
|
||||
- provider detail in ordinary output.
|
||||
|
||||
Historical release documents may retain accurate historical literals.
|
||||
3. Verify canonical ownership:
|
||||
|
||||
- architecture owns invariants and boundaries;
|
||||
- config owns operator profile and credential behavior, but no repair field;
|
||||
- PromptKit integration owns logical prompt/profile/output contracts;
|
||||
- CLI owns result fields;
|
||||
- operations owns explicit debug handling and old comparison-bundle cleanup;
|
||||
- comparison integration owns the complete v2 manifest; and
|
||||
- internal documents own implementation flow without duplicating the public
|
||||
references.
|
||||
|
||||
4. Verify maintained examples remain valid, secret-free, and tested. The local
|
||||
`weather-light` example remains a standalone endpoint-only profile rather
|
||||
than inheriting an OpenRouter backend it cannot clear.
|
||||
5. Review the complete diff for architecture leakage. Production packages
|
||||
outside `internal/adapters/promptkit` must not import PromptKit; no
|
||||
application repair loop, provider client, raw provider diagnostic, profile
|
||||
YAML parser, or durable application state may have appeared.
|
||||
6. Review tests under the testing policy. Keep consumer contract and regression
|
||||
coverage, remove accidental duplication of upstream implementation tests,
|
||||
and ensure every default test is offline and repeatable.
|
||||
7. Run the complete validation set:
|
||||
|
||||
```sh
|
||||
gofmt -w <all changed Go files>
|
||||
GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -race -count=1 ./...
|
||||
GOWORK=off go vet ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go mod tidy -diff
|
||||
go run ./cmd/weatherreporter --help
|
||||
go run ./cmd/weatherreporter compare --help
|
||||
test -z "$(git ls-files go.work go.work.sum)"
|
||||
test ! -e vendor
|
||||
git diff --check
|
||||
```
|
||||
|
||||
8. Confirm `go.mod` has no `replace`, the resolved PromptKit module is exactly
|
||||
v0.8.0, and no live credential or provider call occurred during validation.
|
||||
9. After every check passes, update this plan's status to Completed and add a
|
||||
concise completion note listing the implemented stages. Do not delete either
|
||||
roadmap until the maintainer has reviewed the implementation. Do not create
|
||||
a release document or tag; release preparation remains a separate maintainer
|
||||
action once a version is selected.
|
||||
|
||||
## Completion Standard
|
||||
|
||||
The implementation is complete only when all nine stages pass their focused
|
||||
and repository-wide checks, all locked decisions are observable in code and
|
||||
canonical documentation, and the feature roadmap's completion criteria are
|
||||
satisfied. Passing compilation alone is insufficient. The final state must
|
||||
demonstrate repaired success, repair exhaustion, comparison provenance,
|
||||
endpoint-only routing, optional credentials, inherited profiles, Rakestrawhome
|
||||
inspection, safe ordinary provider failures, secure debug-only detail, and
|
||||
unchanged publication and concurrency invariants.
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
# LLM Profile Comparison Roadmap
|
||||
|
||||
Status: Implemented; retained as the feature roadmap.
|
||||
|
||||
## Purpose
|
||||
|
||||
Prompt development currently requires separate Weatherreporter invocations to
|
||||
compare several LLM profiles. Those invocations may collect different weather
|
||||
snapshots or rebuild inputs at different times, making model output harder to
|
||||
compare and slowing prompt iteration.
|
||||
|
||||
Weatherreporter should provide a first-class `compare` command that resolves
|
||||
one report, prepares one exact data package, executes the same prompt and data
|
||||
package concurrently through several explicitly selected Promptkit profiles,
|
||||
and publishes a self-contained local comparison bundle.
|
||||
|
||||
An illustrative invocation is:
|
||||
|
||||
```sh
|
||||
weatherreporter compare daily \
|
||||
--date 2026-08-24 \
|
||||
--profile weather-light \
|
||||
--profile weather-balanced \
|
||||
--profile weather-deep
|
||||
```
|
||||
|
||||
This is a prompt-development workflow, not an automated model evaluator. Its
|
||||
output gives a maintainer consistent evidence for human comparison without
|
||||
assigning scores or selecting a winner.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
Configurable output directories are implemented. Profile comparison must reuse
|
||||
the current [configuration reference](../config.md) and [operations
|
||||
guide](../operations.md) rather than introduce a second destination policy.
|
||||
|
||||
## User Intent
|
||||
|
||||
The command is intended for deliberate evaluation of multiple profiles,
|
||||
including sets of eight to twelve candidate models. Concurrency is part of the
|
||||
feature, not a future optimization. Promptkit should retain ownership of
|
||||
backend-specific capacity, while Weatherreporter owns comparison-wide
|
||||
coordination, cancellation, deterministic results, and artifact publication.
|
||||
|
||||
Every profile must receive byte-for-byte identical prompt input. Weather data,
|
||||
derived facts, modules, prompt metadata, and serialized YAML must not be
|
||||
recollected or rebuilt separately for individual profiles.
|
||||
|
||||
Comparison bundles are explicitly requested, operator-owned development
|
||||
outputs. They are not Weatherreporter state, are never read implicitly by a
|
||||
later run, and do not weaken the ordinary stateless execution model.
|
||||
|
||||
## Command Contract
|
||||
|
||||
The command form is:
|
||||
|
||||
```text
|
||||
weatherreporter compare REPORT [options]
|
||||
```
|
||||
|
||||
`REPORT` accepts the implemented generated-text reports: `daily`, `today`,
|
||||
`tomorrow`, and `hourly`. Report-date behavior matches `generate`: `daily`
|
||||
requires `--date`, `today` may accept an explicit date or use the current local
|
||||
date, and the remaining report types retain their existing period policies.
|
||||
|
||||
The command accepts the applicable common generation options, including
|
||||
`--config`, `--units`, `--tz`, `--date`, `--llm-debug-dir`, and `--quiet`, plus:
|
||||
|
||||
- repeatable `--profile PROFILE_ID` selections;
|
||||
- `--out-dir PATH` for the exact comparison-bundle directory; and
|
||||
- `--replace` to authorize guarded replacement of a recognized existing
|
||||
comparison bundle.
|
||||
|
||||
At least two distinct, nonblank profile IDs are required. Their command-line
|
||||
order is significant and is preserved in filenames, summaries, and
|
||||
`comparison.json`. Duplicate profile IDs are rejected rather than silently
|
||||
deduplicated or executed twice.
|
||||
|
||||
Profiles are always explicit for this command. `promptkit.profile` does not add
|
||||
or replace a comparison selection, but all other effective Promptkit settings,
|
||||
profile-source precedence, local backend configuration, credential lookup, and
|
||||
profile overrides remain in force.
|
||||
|
||||
The initial feature has no Weatherreporter-specific concurrency flag or
|
||||
artificial profile-count ceiling. The explicit profile list bounds the
|
||||
comparison, and Promptkit owns capacity enforcement for each selected backend.
|
||||
|
||||
## Preparation And Execution Invariants
|
||||
|
||||
A comparison has this logical lifecycle:
|
||||
|
||||
1. Parse and validate the report, date, profile list, configuration, output
|
||||
destination, and replacement authorization.
|
||||
2. Resolve the report definition, valid period, prompt identity, and default
|
||||
output name once.
|
||||
3. Inspect the exact prompt once and preflight every selected profile,
|
||||
including its effective backend, model, and required credential
|
||||
availability, before weather collection.
|
||||
4. Collect weather data exactly once.
|
||||
5. Build collected and derived facts, the module snapshot, briefing metadata,
|
||||
and the prompt data package exactly once.
|
||||
6. Marshal the data package to one immutable YAML byte sequence exactly once.
|
||||
7. Execute the exact prompt version concurrently for every selected profile,
|
||||
passing the same immutable YAML bytes to every execution.
|
||||
8. Validate and render each profile result independently from the shared
|
||||
deterministic inputs.
|
||||
9. Assemble results in requested-profile order and publish one coherent
|
||||
comparison bundle.
|
||||
|
||||
This lifecycle describes the required end-state behavior rather than an
|
||||
implementation-stage sequence.
|
||||
|
||||
No profile execution may cause recollection, report re-resolution, module
|
||||
rebuilding, or data-package remarshalling. Prompt execution may perform
|
||||
Promptkit-owned validation or repair behavior, but Weatherreporter does not
|
||||
retry a failed comparison execution independently.
|
||||
|
||||
## Concurrency And Cancellation
|
||||
|
||||
Weatherreporter starts one execution for each preflighted profile and permits
|
||||
them to run concurrently through one shared, concurrency-safe Promptkit
|
||||
executor. Promptkit's engine-local backend pools remain authoritative for
|
||||
backend concurrency and waiting capacity. Profiles routed to a limited local
|
||||
backend therefore respect its configured limit, while profiles routed to
|
||||
other backends may proceed independently.
|
||||
|
||||
Weatherreporter must not add a second semaphore that obscures or overrides
|
||||
Promptkit's backend policy. It must safely coordinate goroutine lifecycles,
|
||||
result collection, debug callbacks, and output assembly without data races.
|
||||
|
||||
One profile failure does not cancel its peers. Provider, capacity, validation,
|
||||
and rendering failures are recorded for that profile while other executions
|
||||
continue. Cancellation or deadline expiration of the comparison command is
|
||||
propagated to every outstanding execution, prevents new publication, and is
|
||||
joined without leaking goroutines.
|
||||
|
||||
Completion order must not affect filenames, manifest order, CLI summaries, or
|
||||
error aggregation. Those outputs always follow the original `--profile`
|
||||
order.
|
||||
|
||||
## Output Destination
|
||||
|
||||
Without `--out-dir`, Weatherreporter derives a comparison directory from the
|
||||
resolved report's existing default Markdown filename by removing `.md` and
|
||||
prefixing `comparison-`:
|
||||
|
||||
| Report output | Comparison directory |
|
||||
| --- | --- |
|
||||
| `today.md` | `comparison-today/` |
|
||||
| `tomorrow.md` | `comparison-tomorrow/` |
|
||||
| `hourly.md` | `comparison-hourly/` |
|
||||
| `daily-2026-08-24.md` | `comparison-daily-2026-08-24/` |
|
||||
|
||||
The derived directory is created beneath `output.directory` when configured,
|
||||
or beneath the present working directory otherwise. An explicit `--out-dir`
|
||||
is the exact bundle directory, resolves relative to the present working
|
||||
directory when necessary, and overrides `output.directory` completely.
|
||||
|
||||
All destination selection and validation completes before weather collection.
|
||||
The resolved comparison directory is returned in the command's structured
|
||||
result.
|
||||
|
||||
## Comparison Bundle
|
||||
|
||||
A successful three-profile comparison has a flat layout:
|
||||
|
||||
```text
|
||||
comparison-daily-2026-08-24/
|
||||
├── comparison.json
|
||||
├── data-package.yml
|
||||
├── 01-weather-light.md
|
||||
├── 02-weather-balanced.md
|
||||
└── 03-weather-deep.md
|
||||
```
|
||||
|
||||
`data-package.yml` contains the exact YAML bytes passed to every Promptkit
|
||||
execution. It is written once and its SHA-256 digest is recorded in the
|
||||
manifest.
|
||||
|
||||
Each report filename begins with its one-based, zero-padded selection position
|
||||
and a filesystem-safe representation of the requested logical profile ID. The
|
||||
safe representation must not permit absolute paths, traversal, separators, or
|
||||
control characters. The manifest retains the exact case-sensitive profile ID,
|
||||
so filename normalization never becomes the authority for profile identity.
|
||||
|
||||
`comparison.json` is the authoritative index for the bundle. It uses an
|
||||
explicit schema version and records safe comparison information including:
|
||||
|
||||
- comparison identity and start and finish timestamps;
|
||||
- report ID, resolved valid period, and effective timezone;
|
||||
- prompt ID, version, and inspected prompt hash;
|
||||
- the relative data-package filename and SHA-256 digest;
|
||||
- total, succeeded, and failed profile counts; and
|
||||
- one ordered result per requested profile containing the exact profile ID,
|
||||
resolved backend and model, relative report filename when present,
|
||||
execution and validation status, and safe error information when failed.
|
||||
|
||||
The manifest and normal command summary must not contain credentials, provider
|
||||
request bodies, raw model output, rendered prompts, schemas, provider
|
||||
endpoints, or other content-rich diagnostics. The explicit data package and
|
||||
generated reports contain the development material the user requested and
|
||||
must be handled as operator-owned potentially sensitive output.
|
||||
|
||||
## Failure And Publication Policy
|
||||
|
||||
Failure before concurrent execution, including invalid profiles, missing
|
||||
credentials, collection failure, preparation failure, or unsafe destination,
|
||||
publishes no comparison bundle and performs no model calls where the failure
|
||||
is discoverable during preflight.
|
||||
|
||||
After execution begins, Weatherreporter waits for every non-cancelled profile.
|
||||
If one or more profiles fail, it still publishes a coherent partial bundle
|
||||
containing `data-package.yml`, every successfully rendered report, and a
|
||||
manifest describing all successes and failures. It then returns a non-zero
|
||||
exit status. A failed profile has no report file unless a future contract
|
||||
explicitly introduces a separately named diagnostic artifact.
|
||||
|
||||
Bundle contents are staged outside the destination and published only after
|
||||
the manifest is complete. Ordinary publication accepts only an absent or empty
|
||||
target directory. A nonempty existing directory fails without modification
|
||||
unless `--replace` is present.
|
||||
|
||||
`--replace` may replace only the exact resolved target and must reject broad or
|
||||
unsafe targets such as a filesystem root, the present working directory, a
|
||||
symlink, or an unrecognized nonempty directory. A recognized prior bundle must
|
||||
contain a valid Weatherreporter comparison manifest. Replacement publishes the
|
||||
new complete or coherent partial bundle as a unit, prevents stale reports from
|
||||
the prior comparison from surviving, and preserves or restores the prior
|
||||
bundle if the final replacement operation fails.
|
||||
|
||||
An interrupted or cancelled comparison does not replace an existing bundle.
|
||||
Temporary staging artifacts are cleaned up on ordinary failure and
|
||||
cancellation without scanning or modifying unrelated directories.
|
||||
|
||||
## Prompt Debugging
|
||||
|
||||
The existing `--llm-debug-dir` mechanism remains available. Concurrent
|
||||
comparison executions require distinct, deterministic debug identities that
|
||||
include the comparison and exact profile selection so callbacks cannot collide
|
||||
or overwrite another profile's artifacts.
|
||||
|
||||
Debug writing must be concurrency-safe and retain the existing permission,
|
||||
redaction, explicit-opt-in, and path-containment guarantees. Debug artifacts
|
||||
remain separate from the comparison bundle; the bundle does not implicitly
|
||||
enable full Promptkit diagnostics.
|
||||
|
||||
## Notification Policy
|
||||
|
||||
Profile comparisons never invoke Distributor notification, even when
|
||||
notification is enabled in the effective configuration. Comparison reports
|
||||
are local development artifacts rather than ordinary report publications.
|
||||
|
||||
Adding comparison publication or upload behavior would require a separate
|
||||
accepted feature scope and explicit operator authorization.
|
||||
|
||||
## Architectural End State
|
||||
|
||||
Application orchestration exposes a reusable prepared-report boundary that
|
||||
contains the resolved report, shared collected and derived facts, module
|
||||
snapshot, briefing metadata, generated-text handler, render inputs, and exact
|
||||
serialized data package. That boundary is immutable during concurrent profile
|
||||
execution.
|
||||
|
||||
Ordinary `generate` behavior continues to prepare once and execute once.
|
||||
`compare` prepares once and executes many without duplicating the generation
|
||||
workflow or calling `GenerateDetailed` in a loop. Shared preparation,
|
||||
profile-specific Promptkit execution, structured-output validation, rendering,
|
||||
and artifact publication remain distinct responsibilities.
|
||||
|
||||
The Promptkit adapter remains the only owner of dependency-specific types and
|
||||
engine calls. The CLI owns parsing and user-facing summaries. The configuration
|
||||
package owns configuration. Application orchestration owns comparison order,
|
||||
concurrency lifecycle, failure aggregation, and bundle publication. Domain,
|
||||
prompt-input, generated-text, and template packages retain their existing
|
||||
deterministic contracts.
|
||||
|
||||
## Scope
|
||||
|
||||
The completed feature includes:
|
||||
|
||||
- the `compare` CLI command for every implemented generated-text report;
|
||||
- repeatable explicit profile selection and validation;
|
||||
- configured and CLI output-directory integration through the implemented
|
||||
destination policy;
|
||||
- one-time report resolution, collection, deterministic preparation, and YAML
|
||||
serialization;
|
||||
- concurrent execution through one Promptkit executor with backend capacity
|
||||
respected;
|
||||
- independent validation and rendering with deterministic ordered results;
|
||||
- the flat, versioned comparison-bundle contract;
|
||||
- safe filename derivation and data-package hashing;
|
||||
- coherent partial-result publication and non-zero failure behavior;
|
||||
- guarded whole-bundle replacement through `--replace`;
|
||||
- comparison-aware, concurrency-safe optional prompt debugging;
|
||||
- explicit suppression of Distributor notification;
|
||||
- structured normal and quiet-mode CLI behavior consistent with existing
|
||||
commands;
|
||||
- focused race-safe tests across configuration, CLI, application,
|
||||
Promptkit-adapter, rendering, and filesystem boundaries; and
|
||||
- updates to every affected canonical user, operator, architecture,
|
||||
integration, and internal document.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The feature is additive. Existing `generate` and `run` commands, report
|
||||
definitions, profile defaults, configuration, output filenames, notification
|
||||
behavior, and exit contracts remain unchanged.
|
||||
|
||||
The comparison manifest and bundle layout begin as versioned contracts. They
|
||||
do not become inputs accepted by Weatherreporter, and no backward-compatible
|
||||
replay or long-term archive guarantee is implied beyond identifying the schema
|
||||
used to interpret a produced bundle.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
Tests should provide durable coverage for:
|
||||
|
||||
- report and date parsing consistent with `generate`;
|
||||
- rejection of fewer than two profiles, blanks, and duplicates;
|
||||
- inspection of the exact prompt and every profile before collection;
|
||||
- no collection or model execution after a preflight failure;
|
||||
- exactly one weather collection and one preparation for several profiles;
|
||||
- byte-for-byte identical data-package input in every execution;
|
||||
- observable concurrent execution through a concurrency-safe fake executor;
|
||||
- respect for Promptkit-owned backend capacity in an assembled adapter test
|
||||
where that integration adds distinct confidence;
|
||||
- deterministic filenames, manifest order, summaries, and errors under varied
|
||||
completion order;
|
||||
- continuation and coherent partial publication after one profile fails;
|
||||
- cancellation propagation, goroutine completion, and preservation of an
|
||||
existing destination;
|
||||
- destination precedence and each derived default directory;
|
||||
- safe filename handling for unusual valid profile IDs;
|
||||
- absent, empty, occupied, symlinked, unsafe, recognized, and unrecognized
|
||||
replacement targets;
|
||||
- removal of stale prior report files during authorized whole-bundle
|
||||
replacement;
|
||||
- exact package digest and manifest/result consistency;
|
||||
- concurrency-safe, non-colliding opt-in debug artifacts; and
|
||||
- absence of Distributor calls for complete and partial comparisons.
|
||||
|
||||
Concurrency and replacement behavior require race-enabled and consequential
|
||||
failure-path coverage. Tests must remain deterministic, offline, credential
|
||||
free, and independent of real Promptkit providers or machine-specific paths.
|
||||
|
||||
## Documentation End State
|
||||
|
||||
Once implemented, the [CLI reference](../cli.md) owns command syntax, flags,
|
||||
summary, and exit behavior. The [operations guide](../operations.md) owns the
|
||||
bundle lifecycle, replacement procedure, sensitivity guidance, and practical
|
||||
prompt-comparison workflow. The [architecture policy](../policy/architecture.md)
|
||||
owns the statelessness, concurrency, notification, and publication invariants.
|
||||
|
||||
The [Promptkit integration guide](../integrations/promptkit.md) should describe
|
||||
the consumer-visible multi-profile execution boundary without duplicating
|
||||
Promptkit's backend-capacity reference. The versioned manifest and flat bundle
|
||||
format belong in a focused contract under `docs/integrations/`. App
|
||||
orchestration, prompt input, generated text, prompt debugging, and bundle
|
||||
publication mechanics belong in focused documents under `docs/internal/`.
|
||||
|
||||
Current-state documentation must not describe profile comparison as available
|
||||
until the implementation lands.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This roadmap does not introduce:
|
||||
|
||||
- automatic model scoring, ranking, recommendation, or winner selection;
|
||||
- semantic or textual diff generation between reports;
|
||||
- repeated sampling of one profile or statistical evaluation;
|
||||
- prompt or profile editing through Weatherreporter;
|
||||
- replaying a saved data package as command input;
|
||||
- comparing several report types in one command;
|
||||
- Weatherreporter-owned backend concurrency or queue configuration;
|
||||
- automatic retries beyond Promptkit's existing execution contract;
|
||||
- Distributor upload or other external publication;
|
||||
- comparison history, indexing, retention, cleanup schedules, or implicit
|
||||
discovery of prior bundles; or
|
||||
- changes to ordinary report content or normal generation behavior.
|
||||
|
||||
Any later automated evaluation, replay, sampling, or publication feature
|
||||
requires a separate accepted roadmap.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The feature is complete when a maintainer can select several Promptkit
|
||||
profiles, have them execute concurrently against one exact prepared report
|
||||
package, and receive a safe, flat, deterministic comparison bundle whose
|
||||
manifest accurately describes every success and failure. Configured and
|
||||
explicit destinations must follow the accepted output policy, replacement must
|
||||
never mix or silently destroy unrelated contents, cancellation and partial
|
||||
failure must be race-safe, ordinary notification must remain disabled, and all
|
||||
affected canonical documentation must describe the implemented behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The scope, prerequisites, user intent, and target behavior are defined
|
||||
above.
|
||||
488
docs/roadmap/promptkit-v0.8.0.md
Normal file
488
docs/roadmap/promptkit-v0.8.0.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# PromptKit v0.8.0 Upgrade Roadmap
|
||||
|
||||
Status: Implemented.
|
||||
|
||||
## Purpose
|
||||
|
||||
WeatherReporter should upgrade its PromptKit dependency from `v0.5.0` to
|
||||
`v0.8.0` and deliberately adopt the useful consumer-facing capabilities added
|
||||
in `v0.6.0`, `v0.7.0`, and `v0.8.0`. The upgrade should improve output-contract
|
||||
reliability, profile composition, local and alternate endpoint support, and
|
||||
provider-failure diagnosis without moving PromptKit responsibilities into
|
||||
WeatherReporter or weakening the application's stateless and security
|
||||
boundaries.
|
||||
|
||||
This roadmap defines the intended scope, policy, and end state. The
|
||||
[implementation plan](implementation.md) owns the procedure for reaching that
|
||||
state.
|
||||
|
||||
## User Intent
|
||||
|
||||
The upgrade is intended to:
|
||||
|
||||
- use PromptKit's bounded output repair to recover from occasional malformed
|
||||
structured weather prose;
|
||||
- keep WeatherReporter's domain profile IDs stable while inheriting maintained
|
||||
PromptKit model definitions;
|
||||
- make PromptKit's additional built-in backend and profile available for
|
||||
explicit generation and profile comparisons;
|
||||
- support unauthenticated or optionally authenticated OpenAI-compatible
|
||||
endpoints without inventing a WeatherReporter transport layer;
|
||||
- make provider HTTP failures more actionable under an explicit
|
||||
WeatherReporter disclosure policy; and
|
||||
- receive PromptKit's intervening correctness, safety, cancellation, resource,
|
||||
and efficiency improvements as part of one tested dependency upgrade.
|
||||
|
||||
The model ladder and report assignments do not change as part of this work:
|
||||
Hourly continues to select `weather-light`; Daily, Today, and Tomorrow continue
|
||||
to select `weather-balanced`; and `weather-deep` remains available for explicit
|
||||
selection. This upgrade does not promote the new Rakestrawhome profile into
|
||||
that default ladder.
|
||||
|
||||
## Current State
|
||||
|
||||
WeatherReporter currently depends on
|
||||
`gitea.maximumdirect.net/eric/promptkit` at `v0.5.0`. The PromptKit adapter
|
||||
supplies embedded prompts, JSON Schemas, and
|
||||
application-fallback profiles, plus an optional configured profile source and
|
||||
the conventional local backend.
|
||||
|
||||
The four generated-text prompts are exact version `2.0.0` JSON Schema prompts.
|
||||
They omit `repair_attempts`, so execution is single-pass. The project-owned
|
||||
`promptexec.OutputContract` and validation result also omit repair budgets and
|
||||
actual repair counts.
|
||||
|
||||
The three embedded WeatherReporter profiles duplicate the effective fields of
|
||||
these PromptKit built-ins:
|
||||
|
||||
| WeatherReporter profile | PromptKit built-in with the same target |
|
||||
| --- | --- |
|
||||
| `weather-light` | `deepseek-4-flash` |
|
||||
| `weather-balanced` | `gemini-flash-latest` |
|
||||
| `weather-deep` | `claude-sonnet-latest` |
|
||||
|
||||
WeatherReporter preflights any nonblank `api_key_env` as a required credential,
|
||||
even though PromptKit v0.7.0 distinguishes an optional environment source from
|
||||
an explicit `APIKeyRequired` target. Provider generation failures are reduced
|
||||
to WeatherReporter's safe `generation` category; the PromptKit dependency error
|
||||
is retained as a hidden cause, but its structured HTTP status and provider
|
||||
diagnostics are not mapped into project-owned values.
|
||||
|
||||
The maintained `weather-light` local override is an endpoint-only profile, and
|
||||
the configuration contract says endpoint-only profiles are supported. PromptKit
|
||||
inspection correctly reports no backend ID for that form, but WeatherReporter
|
||||
application preflight currently requires both a nonblank backend and model.
|
||||
That mismatch prevents the documented example from reaching generation and
|
||||
should be corrected as part of adopting the current PromptKit target contract.
|
||||
|
||||
## Upstream Release Assessment
|
||||
|
||||
### PromptKit v0.6.0
|
||||
|
||||
`v0.6.0` adds no public declarations, but it is a material compatibility and
|
||||
safety release. It centralizes execution-setting, output-contract, endpoint,
|
||||
and JSON-compatible-value validation; makes YAML metadata authoritative for
|
||||
prompt and profile identity; hardens `content_file` containment and regular-file
|
||||
requirements; validates OpenAI-compatible endpoints structurally; bounds JSON
|
||||
trees and successful provider bodies; requires exactly one JSON value in
|
||||
provider responses; preserves cancellation and transport error identities; and
|
||||
reuses schema and rendered-artifact work within an operation.
|
||||
|
||||
WeatherReporter should receive these improvements directly from the dependency
|
||||
and audit its own supported assets and configuration paths against the stricter
|
||||
contracts. It should not duplicate PromptKit's internal validators or tests.
|
||||
The existing embedded prompt paths, inline data-package input, generated-output
|
||||
limit, and adapter boundary remain conceptually correct.
|
||||
|
||||
### PromptKit v0.7.0
|
||||
|
||||
`v0.7.0` adds four potentially useful consumer features:
|
||||
|
||||
- linear, cycle-safe profile inheritance through `base_profile` and
|
||||
`Profile.BaseProfileID`;
|
||||
- the built-in `rakestrawhome` backend and
|
||||
`rakestrawhome-gemma-4-31b` profile;
|
||||
- optional API-key environment sources, with `APIKeyRequired` reserved for an
|
||||
explicit local credential requirement; and
|
||||
- bounded structured `GenerationError` details for non-2xx responses from the
|
||||
built-in OpenAI-compatible client.
|
||||
|
||||
WeatherReporter has no manual `rakestrawhome` registration and uses keyed
|
||||
PromptKit profile literals, so the two source-compatibility hazards called out
|
||||
by the release do not require migration shims. The profile, credential, and
|
||||
error features do require deliberate application-policy choices described
|
||||
below.
|
||||
|
||||
### PromptKit v0.8.0
|
||||
|
||||
`v0.8.0` activates the existing output-contract repair budget. A positive
|
||||
`repair_attempts` value authorizes up to that many corrective model calls after
|
||||
eligible `basic`, `json`, or `json_schema` validation failures. The supported
|
||||
budget is zero through three. Repairs preserve the original rendered
|
||||
conversation, effective target, session, structured-output contract, and
|
||||
backend capacity policy. The final result reports cumulative token usage and
|
||||
the number of corrective calls actually made.
|
||||
|
||||
Repair exhaustion is a completed generation with failed validation, not an
|
||||
operational error. WeatherReporter's existing policy should continue to reject
|
||||
that result and publish no report for that profile. Explicit empty provider
|
||||
content now reaches output validation; for WeatherReporter's JSON Schema
|
||||
prompts it is therefore eligible for repair rather than being misclassified as
|
||||
a malformed provider envelope.
|
||||
|
||||
## Desired End State
|
||||
|
||||
WeatherReporter builds and tests against PromptKit `v0.8.0` with no workspace,
|
||||
vendor, or module replacement dependency. Its public behavior remains
|
||||
stateless, its PromptKit dependency types remain confined to the adapter, and
|
||||
its ordinary summaries and logs remain safe.
|
||||
|
||||
The completed integration:
|
||||
|
||||
- benefits from the v0.6.0 safety and efficiency corrections;
|
||||
- composes WeatherReporter domain profiles from PromptKit's maintained built-in
|
||||
profiles while preserving WeatherReporter-owned leaf IDs and operator
|
||||
override precedence;
|
||||
- accepts a successfully inspected endpoint-only profile with a nonblank model
|
||||
even though it has no logical backend ID;
|
||||
- permits explicit use of PromptKit's Rakestrawhome profile without custom
|
||||
backend wiring;
|
||||
- applies an accepted bounded-repair policy to every operational structured
|
||||
prompt;
|
||||
- validates repair configuration during preflight and records actual repair
|
||||
activity in project-owned result values;
|
||||
- retains PromptKit's cumulative usage accounting in explicit debug output;
|
||||
- distinguishes safe provider HTTP status from potentially sensitive provider
|
||||
diagnostics; and
|
||||
- documents the changed profile, credential, repair, comparison, debug, and
|
||||
failure contracts in their canonical owners.
|
||||
|
||||
## Dependency And Compatibility Policy
|
||||
|
||||
The module requirement should move directly from `v0.5.0` to `v0.8.0`, followed
|
||||
by a clean module tidy. WeatherReporter already requires Go 1.26 while PromptKit
|
||||
`v0.8.0` requires Go 1.25.5, so no Go version change is needed for this upgrade.
|
||||
|
||||
Consumer validation must cover the paths called out by PromptKit v0.6.0:
|
||||
|
||||
- every embedded prompt, content file, schema, and fallback profile inspects
|
||||
through PromptKit `v0.8.0`;
|
||||
- configured single-file and directory profile sources retain their lazy,
|
||||
metadata-authoritative identity and precedence behavior;
|
||||
- malformed selected profiles and invalid local endpoints retain actionable
|
||||
WeatherReporter categories;
|
||||
- the inline YAML data package and prepared-execution path remain within the
|
||||
new JSON and response bounds; and
|
||||
- cancellation, deadline, and backend-capacity identities still cross the
|
||||
adapter correctly.
|
||||
|
||||
PromptKit owns its 16 MiB successful transport-response bound and JSON framing.
|
||||
WeatherReporter retains its stricter 64 KiB generated-text acceptance bound.
|
||||
The consumer suite should protect that relationship without reproducing
|
||||
PromptKit's lower-level transport matrix.
|
||||
|
||||
## Domain Profile Composition
|
||||
|
||||
The embedded profiles should become application-owned aliases:
|
||||
|
||||
```yaml
|
||||
id: weather-light
|
||||
base_profile: deepseek-4-flash
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: weather-balanced
|
||||
base_profile: gemini-flash-latest
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: weather-deep
|
||||
base_profile: claude-sonnet-latest
|
||||
```
|
||||
|
||||
The effective backend, model, timeout, service tier, and reasoning settings
|
||||
must initially remain identical to the current WeatherReporter definitions.
|
||||
The selected leaf remains the durable logical profile identity even though its
|
||||
effective target is inherited.
|
||||
|
||||
Profile source precedence remains:
|
||||
|
||||
1. explicit in-memory profiles used by tests or embedding consumers;
|
||||
2. the configured `profile_file` or `profile_dir` source;
|
||||
3. WeatherReporter's embedded fallback catalog; and
|
||||
4. PromptKit's built-in catalog.
|
||||
|
||||
Sources still do not merge definitions of the same ID. Once a selected
|
||||
definition names `base_profile`, however, each parent ID is resolved through
|
||||
that same precedence order and the resulting linear chain is merged from root
|
||||
to leaf according to PromptKit's inheritance contract. Documentation must make
|
||||
that distinction explicit. A malformed leaf, missing or malformed base, cycle,
|
||||
overlong chain, or incomplete resolved target fails profile inspection before
|
||||
weather collection.
|
||||
|
||||
An operator may continue to replace `weather-light`, `weather-balanced`, or
|
||||
`weather-deep` with a standalone definition. An operator may also define a
|
||||
derived replacement. The maintained local endpoint example should remain
|
||||
standalone because PromptKit profile inheritance has no clearing syntax: using
|
||||
an OpenRouter base would retain its backend identity and capacity policy even
|
||||
when the child replaces the endpoint.
|
||||
|
||||
WeatherReporter should treat the adapter's successful profile inspection as
|
||||
authoritative that PromptKit resolved a usable route. A nonblank model remains
|
||||
required, but backend ID is optional for an endpoint-only profile and should be
|
||||
omitted from safe provenance where unavailable. WeatherReporter still must not
|
||||
surface the endpoint outside explicit debug capture. This aligns application
|
||||
preflight with PromptKit and with the existing CLI, comparison, and debug value
|
||||
shapes, all of which already permit an absent backend identity.
|
||||
|
||||
## Rakestrawhome Availability
|
||||
|
||||
The reserved `rakestrawhome` backend and built-in
|
||||
`rakestrawhome-gemma-4-31b` profile should be supported automatically through
|
||||
ordinary PromptKit selection. Operators may choose that profile with the
|
||||
existing global profile setting or as one entry in `compare`, and PromptKit's
|
||||
backend capacity policy remains authoritative.
|
||||
|
||||
WeatherReporter should not register, wrap, or duplicate the backend or profile,
|
||||
and should not add a Rakestrawhome-specific configuration field. Its canonical
|
||||
PromptKit integration documentation should link to PromptKit for the current
|
||||
built-in catalog and credential contract rather than copying volatile endpoint
|
||||
or capacity values. Offline inspection coverage should prove that the built-in
|
||||
profile crosses the WeatherReporter adapter with the expected logical backend
|
||||
identity.
|
||||
|
||||
## Bounded Structured-Output Repair
|
||||
|
||||
The accepted repair budget belongs to the exact PromptKit output contract, not
|
||||
to a WeatherReporter retry loop. PromptKit alone should construct corrective
|
||||
messages, perform additional calls, enforce the budget, aggregate usage, and
|
||||
coordinate backend capacity. WeatherReporter must not retry provider failures,
|
||||
switch profiles, or layer another repair mechanism around `RunPrepared`.
|
||||
|
||||
All four embedded prompt definitions should declare `repair_attempts: 1`.
|
||||
Because this changes prompt
|
||||
execution behavior, latency, cost, hash, and provenance, each definition and
|
||||
its report-registry binding should advance from exact version `2.0.0` to
|
||||
`2.1.0`. Prompt text and generated-text schemas do not need to change solely
|
||||
for this feature.
|
||||
|
||||
The embedded prompt definition is the per-report pipeline policy owner. This
|
||||
upgrade should not add a global or per-report operator configuration field for
|
||||
repair attempts and should not construct a request-level replacement output
|
||||
contract. A future pipeline may select another budget only through a deliberate
|
||||
prompt-definition and exact-version change.
|
||||
|
||||
The project-owned PromptKit boundary should retain:
|
||||
|
||||
- the configured repair budget in prompt inspection and preparation output
|
||||
contracts;
|
||||
- the number of corrective calls actually made in completed validation;
|
||||
- cumulative PromptKit token usage across initial and corrective calls; and
|
||||
- the final candidate and final validation result only, consistent with the
|
||||
PromptKit contract.
|
||||
|
||||
Prompt inspection and preparation provenance must require the repair budget to
|
||||
match the exact expected prompt definition just as they currently require the
|
||||
format, validation mode, and schema path to match. A zero-attempt successful
|
||||
result is normal when the first candidate passes. A repair-exhausted result
|
||||
continues through WeatherReporter's ordinary `validation_rejected` failure
|
||||
path, and an operational or generation failure during correction remains that
|
||||
profile's ordinary operational failure.
|
||||
|
||||
For concurrent comparison, every profile should use the same prompt repair
|
||||
budget. A corrective call remains part of that profile's one prepared
|
||||
execution and uses PromptKit's existing backend capacity pool. One profile's
|
||||
repair or failure must not cancel independent peers.
|
||||
|
||||
## Repair Observability And Comparison Contract
|
||||
|
||||
The actual repair count is safe operational provenance and should be visible
|
||||
where WeatherReporter already reports completed validation. Generation, batch,
|
||||
and comparison action summaries should expose it without exposing candidates,
|
||||
schemas, or diagnostics. Explicit execution debug output should add it to the
|
||||
validation object alongside PromptKit's already mapped cumulative usage.
|
||||
|
||||
Profile comparison needs this value in `comparison.json`: a successful result
|
||||
that required correction is materially different from a first-pass success
|
||||
when evaluating model reliability, latency, and cost. The manifest should
|
||||
therefore advance to `weatherreporter.comparison.v2` and add a non-negative
|
||||
`repairAttempts` field to each result. The field is zero when no corrective
|
||||
call began, including ordinary first-pass success. A failure carries the count
|
||||
when PromptKit returned a completed validation result; it is omitted only when
|
||||
execution failed before a completed validation result made the value known.
|
||||
|
||||
The v2 manifest should remain flat, strict, deterministic, and atomically
|
||||
published. WeatherReporter does not need to preserve v1 replacement
|
||||
compatibility: comparison bundles are operator-owned development outputs, and
|
||||
the current integration contract intentionally recognizes only its current
|
||||
schema. The release notes and comparison documentation must call out the
|
||||
version change so an operator can remove or relocate an older bundle before
|
||||
using guarded replacement at the same destination.
|
||||
|
||||
## Credential Semantics
|
||||
|
||||
PromptKit v0.7.0 treats `APIKeyEnv` as an optional lookup source. If the
|
||||
environment variable is absent or blank and no direct credential is supplied,
|
||||
the built-in client omits `Authorization` and lets the endpoint respond.
|
||||
`APIKeyRequired` is the distinct signal that a usable credential must be
|
||||
provided locally.
|
||||
|
||||
WeatherReporter cannot supply PromptKit's request-scoped direct API-key value,
|
||||
so a profile reporting `APIKeyRequired` remains unsupported and must fail
|
||||
before weather collection. WeatherReporter should not require a nonblank value
|
||||
for an optional `APIKeyEnv` during application preflight. The built-in client
|
||||
should omit `Authorization` when that source is unavailable and let the
|
||||
endpoint return any authentication failure through the ordinary structured
|
||||
generation-error path.
|
||||
|
||||
## Structured Generation Failures
|
||||
|
||||
PromptKit v0.7.0's `GenerationError` can report a provider HTTP status plus
|
||||
bounded provider code, type, and message. WeatherReporter should consume that
|
||||
type only inside the PromptKit adapter and map any adopted fields into a
|
||||
project-owned immutable error. PromptKit dependency types must not become app
|
||||
or CLI contracts.
|
||||
|
||||
HTTP status is safe enough for ordinary diagnostics. Provider code, type, and
|
||||
message remain untrusted and may contain request or schema fragments. They
|
||||
must never enter ordinary errors, action summaries, comparison manifests,
|
||||
logs, generated reports, or Distributor payloads. The full structured
|
||||
diagnostic belongs only in an explicitly requested secure `--llm-debug-dir`
|
||||
`failure.json` artifact. That artifact may contain PromptKit's normalized
|
||||
bounded fields but never the raw provider body, headers, endpoint, credentials,
|
||||
or reconstructed request.
|
||||
|
||||
Initial-call and corrective-call non-2xx responses should follow the same
|
||||
mapping. Cancellation and deadline categories continue to take precedence over
|
||||
provider classification where PromptKit preserves those identities.
|
||||
|
||||
## Testing Policy
|
||||
|
||||
The default suite must remain deterministic, offline, and credential-free.
|
||||
Use injected PromptKit clients and synthetic embedded or temporary assets for
|
||||
consumer behavior; do not call OpenRouter, Rakestrawhome, or a local endpoint.
|
||||
|
||||
Risk-based coverage should include:
|
||||
|
||||
- all embedded prompts and inherited domain profiles inspecting successfully
|
||||
under PromptKit `v0.8.0`;
|
||||
- unchanged effective targets and report-to-profile assignments after the
|
||||
alias refactor;
|
||||
- external standalone and derived profile precedence, plus selected missing,
|
||||
cyclic, and malformed-base failures at the WeatherReporter boundary;
|
||||
- end-to-end preflight and prepared execution through the maintained
|
||||
endpoint-only `weather-light` override without exposing its endpoint;
|
||||
- offline inspection of `rakestrawhome-gemma-4-31b`;
|
||||
- a first-pass valid result with zero repairs;
|
||||
- an invalid structured result repaired successfully within one corrective
|
||||
call;
|
||||
- one-attempt exhaustion returning failed validation and no published report;
|
||||
- a corrective generation failure retaining its safe category and provider
|
||||
status policy;
|
||||
- cumulative usage and actual repair-count mapping;
|
||||
- comparison peers remaining independent when one profile repairs, exhausts,
|
||||
or fails;
|
||||
- v2 comparison manifest validation and guarded replacement; and
|
||||
- absent or blank optional `APIKeyEnv` values reaching the provider without an
|
||||
`Authorization` header, while `APIKeyRequired` profiles fail preflight.
|
||||
|
||||
Do not reproduce PromptKit's internal matrices for path traversal, JSON tree
|
||||
bounds, response framing, inheritance depth, repair prompt construction, or
|
||||
provider-detail normalization. WeatherReporter tests should protect only its
|
||||
adapter mappings, application policy, provenance, publication, and public
|
||||
contracts. Run ordinary and race-enabled repository tests because the repaired
|
||||
execution path participates in concurrent comparisons.
|
||||
|
||||
## Documentation And Release Impact
|
||||
|
||||
Implementation must update each canonical owner whose contract changes:
|
||||
|
||||
- `docs/policy/architecture.md` for prompt-execution, credential, diagnostic,
|
||||
and comparison invariants;
|
||||
- `docs/config.md` and the maintained local profile example for profile-source,
|
||||
inheritance, and credential semantics;
|
||||
- `docs/integrations/promptkit.md` for exact prompt versions, repair policy,
|
||||
profile composition, Rakestrawhome availability, and safe errors;
|
||||
- `docs/integrations/comparison-bundle.md` for the v2 manifest and repair count;
|
||||
- `docs/cli.md` for repair-count fields in action summaries;
|
||||
- `docs/operations.md` for changed failure behavior and any explicit provider
|
||||
diagnostic capture;
|
||||
- focused internal PromptKit adapter, app orchestration, prompt-debug, and
|
||||
comparison documentation; and
|
||||
- release notes for the dependency jump, prompt version change, possible
|
||||
additional model call, credential behavior, profile inheritance, diagnostic
|
||||
behavior, and comparison schema change.
|
||||
|
||||
Current-state documentation must not describe this behavior until the
|
||||
implementation lands. PromptKit remains the canonical owner of its complete
|
||||
built-in catalogs, YAML merge rules, transport limits, corrective-message
|
||||
construction, and public Go API.
|
||||
|
||||
## Scope
|
||||
|
||||
The completed feature includes:
|
||||
|
||||
- the direct module upgrade and tidy dependency graph;
|
||||
- a v0.6.0 compatibility audit of WeatherReporter's supported PromptKit paths;
|
||||
- inherited WeatherReporter domain profile definitions with unchanged
|
||||
effective targets;
|
||||
- correction of application preflight so PromptKit endpoint-only profiles work
|
||||
as documented while retaining a required model identity;
|
||||
- ordinary access to the Rakestrawhome built-in profile;
|
||||
- PromptKit's optional-credential policy, while direct-key-required profiles
|
||||
remain unsupported;
|
||||
- `repair_attempts` on all operational prompts and exact prompt-version bumps;
|
||||
- project-owned repair budget, actual-attempt, usage, and provenance mappings;
|
||||
- repair observability in action summaries, explicit debug output, and a v2
|
||||
comparison manifest;
|
||||
- safe provider HTTP status in ordinary errors and bounded provider detail only
|
||||
in explicit secure debug capture;
|
||||
- focused offline and race-enabled regression coverage; and
|
||||
- canonical current-state and release documentation updated with the code.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This upgrade does not include:
|
||||
|
||||
- application-implemented repair prompts or provider transport;
|
||||
- retries for HTTP, network, timeout, capacity, or other operational failures;
|
||||
- automatic profile escalation, fallback, ranking, or resampling;
|
||||
- changing the weather profile ladder, default report assignments, or concrete
|
||||
model targets beyond inheriting their maintained PromptKit definitions;
|
||||
- making Rakestrawhome a default or adding provider-specific configuration;
|
||||
- live-provider tests or a permanent benchmark framework;
|
||||
- exposing raw provider responses or sensitive diagnostics routinely;
|
||||
- a general prompt-source or pipeline plugin system; or
|
||||
- compatibility shims for PromptKit versions older than `v0.8.0`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The roadmap is complete when:
|
||||
|
||||
- `go.mod` and `go.sum` resolve PromptKit `v0.8.0` without a replacement,
|
||||
workspace, or vendor tree;
|
||||
- all PromptKit v0.6.0 compatibility points relevant to WeatherReporter have
|
||||
been checked and valid supported inputs retain project-owned error identity;
|
||||
- the three WeatherReporter profiles inherit the intended PromptKit built-ins,
|
||||
retain their logical IDs, and inspect to the intended effective targets;
|
||||
- external standalone and inherited overrides obey documented precedence and
|
||||
failure behavior;
|
||||
- the maintained endpoint-only local override passes application preflight,
|
||||
retains an empty backend ID, and keeps its endpoint out of ordinary values;
|
||||
- `rakestrawhome-gemma-4-31b` is selectable through ordinary generation and
|
||||
comparison paths without WeatherReporter backend registration;
|
||||
- every operational prompt has one bounded repair attempt at exact
|
||||
version `2.1.0`;
|
||||
- inspection, preparation, execution, debug, and comparison values accurately
|
||||
preserve configured and actual repair counts;
|
||||
- first-pass success, repaired success, repair exhaustion, repair generation
|
||||
failure, and explicit empty content follow the documented outcomes;
|
||||
- the v2 comparison bundle distinguishes first-pass and repaired results;
|
||||
- credential preflight accepts absent optional environment credentials while
|
||||
rejecting direct-key-required profiles, and provider-error disclosure does
|
||||
not leak sensitive values;
|
||||
- the default test suite is offline and deterministic, ordinary and race
|
||||
validation pass, and no redundant upstream implementation suite is copied;
|
||||
and
|
||||
- every implemented contract is documented by its canonical current-state
|
||||
owner and disclosed in the eventual release notes.
|
||||
@@ -20,8 +20,9 @@ source:
|
||||
| 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
|
||||
`internal/promptassets`. The generated-text catalog pairs each schema ID with
|
||||
its template ID; keep the matching prompt definition aligned with that pair.
|
||||
`internal/promptassets`. The generated-text catalog requires each report's
|
||||
exact schema/template pair; keep the matching prompt definition aligned with
|
||||
that report-specific triple.
|
||||
|
||||
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
||||
|
||||
@@ -46,11 +47,18 @@ from rendering.
|
||||
calculations, source selection, or prompt-input shaping to a template.
|
||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||
in generated prose merely to compensate for a template change.
|
||||
- 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,
|
||||
schema, validator, render context, and template together. The validation and
|
||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
||||
`.Modules.DerivedDaypartSummaries`, which is a map.
|
||||
`.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:
|
||||
|
||||
@@ -66,7 +74,7 @@ Minimal list pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ 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 |
|
||||
| `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 |
|
||||
| `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
|
||||
to render the section:
|
||||
@@ -91,7 +100,7 @@ to render the section:
|
||||
|
||||
## 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:
|
||||
|
||||
| Field | Purpose |
|
||||
@@ -99,12 +108,6 @@ fields:
|
||||
| `.Report` | Display labels and canonical report timing metadata. |
|
||||
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
||||
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
|
||||
| `.Collected` | Normalized upstream facts for advanced use. |
|
||||
| `.Derived` | Shared calculated facts for advanced use. |
|
||||
|
||||
`.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
|
||||
|
||||
@@ -126,13 +129,15 @@ It is not a source for deterministic weather facts.
|
||||
|
||||
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
||||
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
|
||||
| `.GeneratedText.Summary` | `string` | `string` | Required; at most 4,000 characters. |
|
||||
| `.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` | 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. |
|
||||
|
||||
The JSON schema rejects unknown properties and defines the required fields, but
|
||||
the schema body and validation behavior are documented in [Generated Text
|
||||
internals](internal/generatedtext.md).
|
||||
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
|
||||
|
||||
@@ -141,8 +146,9 @@ Module pointers can be nil when their source or policy permits omission.
|
||||
|
||||
| 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.HasDaypartDetails` | Today |
|
||||
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||
| `.Modules.TodayPlanning` | Today |
|
||||
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||
|
||||
9
go.mod
9
go.mod
@@ -6,10 +6,9 @@ require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.8.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
require 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/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0 h1:jnpazLyyNhWrB2xzwwtUkNUfktkTdkENTwuSPnKiYrc=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.8.0 h1:NGd9hDLu0UMxKbvittMrqM5Ua94eFb+kOE7UIir8l08=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.8.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -22,6 +24,7 @@ type Client struct {
|
||||
TokenEnv string
|
||||
Timeout time.Duration
|
||||
newUploadClient uploadClientFactory
|
||||
pollWait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
@@ -107,6 +110,10 @@ type runStatus struct {
|
||||
|
||||
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 {
|
||||
return newClient(cfg, newDistributorUploadClient)
|
||||
}
|
||||
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
|
||||
TokenEnv: cfg.TokenEnv,
|
||||
Timeout: cfg.Timeout,
|
||||
newUploadClient: factory,
|
||||
pollWait: waitForPoll,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
Status: 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 != "" {
|
||||
uploadResult.RunStatus = &RunStatus{
|
||||
RunID: status.RunID,
|
||||
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
|
||||
return uploadResult, nil
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
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)
|
||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||
return status, err
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(statusPollInterval)
|
||||
select {
|
||||
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:
|
||||
if err := wait(ctx, statusPollInterval); err != nil {
|
||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
|
||||
}
|
||||
|
||||
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 {
|
||||
return status == "succeeded" || status == "failed"
|
||||
}
|
||||
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
|
||||
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) {
|
||||
httpClient := (*http.Client)(nil)
|
||||
httpClient := &http.Client{
|
||||
Transport: boundedResponseTransport{base: http.DefaultTransport, limit: maxDistributorResponseBytes},
|
||||
}
|
||||
if timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
httpClient.Timeout = timeout
|
||||
}
|
||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
||||
if err != nil {
|
||||
return runStatus{}, err
|
||||
}
|
||||
return runStatus{
|
||||
return sanitizeRunStatus(runStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: status.Error,
|
||||
}, nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
type uploadErrorContext struct {
|
||||
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
|
||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
var conflict *distributorupload.IdempotencyConflictError
|
||||
isConflict := errors.As(err, &conflict)
|
||||
err = redactToken(err, ctx.Token)
|
||||
err = safeDistributorDiagnostic(err, ctx.Token)
|
||||
if isConflict {
|
||||
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),
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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 {
|
||||
paths := make([]string, 0, len(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" {
|
||||
t.Fatalf("result = %#v, want accepted run", result)
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
|
||||
}
|
||||
if 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.pollWait = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
||||
if result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
|
||||
}
|
||||
if factory.client.statusCalls != 2 {
|
||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||
if err == nil {
|
||||
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") {
|
||||
t.Fatalf("result = %#v, want failed run status report", result)
|
||||
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 safe failed run status", result)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
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/promptexec"
|
||||
)
|
||||
@@ -23,6 +24,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||
// It supports concurrent Execute calls on the shared executor.
|
||||
type Adapter struct {
|
||||
engine *promptkit.Engine
|
||||
}
|
||||
@@ -151,6 +153,7 @@ func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
||||
Format: string(value.Format),
|
||||
ValidationMode: string(value.ValidationMode),
|
||||
SchemaPath: value.SchemaPath,
|
||||
RepairAttempts: value.RepairAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +194,21 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
|
||||
promptexec.ValidationStatus(value.Validation.Status),
|
||||
string(value.Validation.Mode),
|
||||
value.Validation.SchemaPath,
|
||||
value.Validation.RepairAttempts,
|
||||
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,
|
||||
value.Validation.RepairAttempts,
|
||||
[]string{"generated output exceeds the configured size limit"},
|
||||
)
|
||||
}
|
||||
execution := &promptexec.Execution{
|
||||
RunID: value.RunID,
|
||||
PromptID: value.PromptID,
|
||||
@@ -215,11 +231,11 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
|
||||
EndedAt: value.EndTime,
|
||||
Duration: value.Duration,
|
||||
Validation: validation,
|
||||
RawOutput: []byte(value.RawOutput),
|
||||
RawOutput: rawOutput,
|
||||
}
|
||||
if captureDebug {
|
||||
execution.Debug = &promptexec.ExecutionDebug{
|
||||
RawOutput: append([]byte(nil), value.RawOutput...),
|
||||
RawOutput: append([]byte(nil), rawOutput...),
|
||||
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||
}
|
||||
}
|
||||
@@ -247,13 +263,12 @@ func copyInputHashes(values map[string]string) map[string]string {
|
||||
|
||||
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||
parameters := struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ExtraParams map[string]any `json:"extra_params"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
}{
|
||||
Temperature: value.Temperature,
|
||||
MaxTokens: value.MaxTokens,
|
||||
@@ -261,7 +276,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||
TimeoutSeconds: value.TimeoutSeconds,
|
||||
ServiceTier: value.ServiceTier,
|
||||
ReasoningEffort: value.ReasoningEffort,
|
||||
ExtraParams: value.ExtraParams,
|
||||
}
|
||||
data, _ := json.Marshal(parameters)
|
||||
return data
|
||||
@@ -288,6 +302,16 @@ func classifyError(err error) error {
|
||||
if errors.As(err, &capacityError) {
|
||||
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
||||
}
|
||||
var generationError *promptkit.GenerationError
|
||||
if errors.As(err, &generationError) {
|
||||
return promptexec.NewGenerationError(
|
||||
generationError.StatusCode(),
|
||||
generationError.ProviderCode(),
|
||||
generationError.ProviderType(),
|
||||
generationError.ProviderMessage(),
|
||||
err,
|
||||
)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, promptkit.ErrInvalidConfig):
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||
|
||||
@@ -4,14 +4,19 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
@@ -19,9 +24,17 @@ type fakeClient struct {
|
||||
mu sync.Mutex
|
||||
response *promptkit.GenerateResponse
|
||||
err error
|
||||
outcomes []generationOutcome
|
||||
next int
|
||||
calls int
|
||||
requests []promptkit.GenerateRequest
|
||||
block bool
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
type generationOutcome struct {
|
||||
response *promptkit.GenerateResponse
|
||||
err error
|
||||
}
|
||||
|
||||
type recordingReader struct {
|
||||
@@ -44,9 +57,18 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
||||
client.calls++
|
||||
client.requests = append(client.requests, request)
|
||||
block := client.block
|
||||
started := client.started
|
||||
response := client.response
|
||||
err := client.err
|
||||
if client.next < len(client.outcomes) {
|
||||
outcome := client.outcomes[client.next]
|
||||
client.next++
|
||||
response, err = outcome.response, outcome.err
|
||||
}
|
||||
client.mu.Unlock()
|
||||
if started != nil {
|
||||
started <- struct{}{}
|
||||
}
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
@@ -54,6 +76,36 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
||||
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 {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
@@ -66,13 +118,19 @@ func (client *fakeClient) request() promptkit.GenerateRequest {
|
||||
return client.requests[0]
|
||||
}
|
||||
|
||||
func (client *fakeClient) allRequests() []promptkit.GenerateRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return append([]promptkit.GenerateRequest(nil), client.requests...)
|
||||
}
|
||||
|
||||
func TestInspectPromptAndProfile(t *testing.T) {
|
||||
adapter := newTestAdapter(t, &fakeClient{})
|
||||
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.0.0")
|
||||
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.1.0")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPrompt() error = %v", err)
|
||||
}
|
||||
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.0.0" || inspection.DefaultProfileID != "weather-balanced" {
|
||||
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.1.0" || inspection.DefaultProfileID != "weather-balanced" || inspection.Output.RepairAttempts != 1 {
|
||||
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" {
|
||||
@@ -139,15 +197,38 @@ model: file-light
|
||||
}
|
||||
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
|
||||
|
||||
directory := testProfileDirectory(t, `id: weather-light
|
||||
directory := testProfileDirectory(t, map[string]string{"profile.yml": `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")
|
||||
|
||||
derived := writeProfileFile(t, `id: weather-light
|
||||
base_profile: gemini-flash-latest
|
||||
`)
|
||||
derivedAdapter, err := New(Config{ProfileFile: derived})
|
||||
if err != nil {
|
||||
t.Fatalf("New(derived profile) error = %v", err)
|
||||
}
|
||||
assertProfile(t, derivedAdapter, "weather-light", "openrouter", "~google/gemini-flash-latest")
|
||||
}
|
||||
|
||||
func TestConfiguredBaseProfileOverridesEmbeddedProfileTarget(t *testing.T) {
|
||||
directory := testProfileDirectory(t, map[string]string{
|
||||
"deepseek.yml": `id: deepseek-4-flash
|
||||
backend: local
|
||||
model: shadowed-deepseek
|
||||
`,
|
||||
})
|
||||
adapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
assertProfile(t, adapter, "weather-light", promptkit.BackendLocal, "shadowed-deepseek")
|
||||
}
|
||||
|
||||
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
|
||||
@@ -158,19 +239,69 @@ func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T)
|
||||
assertProfile(t, adapter, "weather-light", "", "weather-local")
|
||||
}
|
||||
|
||||
func TestMaintainedWeatherLightLocalProfileExampleExecutesThroughProductionClient(t *testing.T) {
|
||||
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
credentialSource string
|
||||
}{
|
||||
{name: "without credential source"},
|
||||
{name: "with blank optional credential source", credentialSource: "\napi_key_env: WEATHERREPORTER_TEST_MISSING_KEY\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var authorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
authorization = request.Header.Get("Authorization")
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(writer, `{"choices":[{"message":{"content":%q}}],"usage":{"prompt_tokens":12,"completion_tokens":8,"total_tokens":20}}`, validResponse().Content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
example, err := os.ReadFile(filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := strings.Replace(string(example), "http://127.0.0.1:11434/v1", server.URL+"/v1", 1) + test.credentialSource
|
||||
adapter, err := New(Config{ProfileFile: writeProfileFile(t, profile)})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
request := testExecuteRequest()
|
||||
request.ProfileID = "weather-light"
|
||||
var preparation promptexec.Preparation
|
||||
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
|
||||
preparation = value
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if authorization != "" {
|
||||
t.Fatalf("Authorization header = %q, want absent", authorization)
|
||||
}
|
||||
if preparation.ProfileID != "weather-light" || preparation.BackendID != "" || preparation.ModelName != "weather-local" || preparation.Output.RepairAttempts != 1 {
|
||||
t.Fatalf("preparation = %#v", preparation)
|
||||
}
|
||||
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "" || result.ModelName != "weather-local" || result.Validation.Status != promptexec.ValidationPassed || result.Validation.RepairAttempts != 0 {
|
||||
t.Fatalf("execution = %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
|
||||
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: other-profile
|
||||
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `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
|
||||
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `id: weather-light
|
||||
backend: openrouter
|
||||
`)})
|
||||
`})})
|
||||
if err != nil {
|
||||
t.Fatalf("New(malformed profile) error = %v", err)
|
||||
}
|
||||
@@ -179,6 +310,78 @@ backend: openrouter
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResolutionReturnsConfiguredInheritanceFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile string
|
||||
profiles map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing base",
|
||||
profile: "missing-base",
|
||||
profiles: map[string]string{"missing.yml": `id: missing-base
|
||||
base_profile: unavailable
|
||||
`},
|
||||
},
|
||||
{
|
||||
name: "cyclic bases",
|
||||
profile: "first",
|
||||
profiles: map[string]string{
|
||||
"first.yml": `id: first
|
||||
base_profile: second
|
||||
`,
|
||||
"second.yml": `id: second
|
||||
base_profile: first
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed base",
|
||||
profile: "child",
|
||||
profiles: map[string]string{
|
||||
"child.yml": `id: child
|
||||
base_profile: malformed
|
||||
`,
|
||||
"malformed.yml": `id: malformed
|
||||
base_profile: [not-a-profile]
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "incomplete target",
|
||||
profile: "incomplete",
|
||||
profiles: map[string]string{"incomplete.yml": `id: incomplete
|
||||
backend: openrouter
|
||||
`},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, test.profiles)})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if _, err := adapter.InspectProfile(context.Background(), test.profile); err == nil {
|
||||
t.Fatal("InspectProfile() error = nil, want configured inheritance error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakestrawhomeBuiltInProfileInspectsOffline(t *testing.T) {
|
||||
adapter, err := New(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
profile, err := adapter.InspectProfile(context.Background(), "rakestrawhome-gemma-4-31b")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile() error = %v", err)
|
||||
}
|
||||
if profile.ProfileID != "rakestrawhome-gemma-4-31b" || profile.BackendID != "rakestrawhome" || profile.ModelName == "" {
|
||||
t.Fatalf("profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
|
||||
adapter, err := New(Config{})
|
||||
if err != nil {
|
||||
@@ -251,7 +454,7 @@ func TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
|
||||
}
|
||||
request := promptexec.ExecuteRequest{
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
PromptVersion: "2.0.0",
|
||||
PromptVersion: "2.1.0",
|
||||
ProfileID: "weather-light",
|
||||
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
|
||||
}
|
||||
@@ -316,6 +519,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) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
@@ -340,6 +567,131 @@ func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMapsCorrectiveGenerationResults(t *testing.T) {
|
||||
valid := `{"summary":"valid"}`
|
||||
invalid := `{"summary":42}`
|
||||
tests := []struct {
|
||||
name string
|
||||
outcomes []generationOutcome
|
||||
wantStatus promptexec.ValidationStatus
|
||||
wantRepairs int
|
||||
wantCalls int
|
||||
wantRaw string
|
||||
wantUsage promptexec.TokenUsage
|
||||
}{
|
||||
{
|
||||
name: "first pass valid",
|
||||
outcomes: []generationOutcome{{response: generationResponse(valid, 2, 3, 5)}},
|
||||
wantStatus: promptexec.ValidationPassed,
|
||||
wantRepairs: 0,
|
||||
wantCalls: 1,
|
||||
wantRaw: valid,
|
||||
wantUsage: promptexec.TokenUsage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
|
||||
},
|
||||
{
|
||||
name: "empty output repaired",
|
||||
outcomes: []generationOutcome{{response: generationResponse("", 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
|
||||
wantStatus: promptexec.ValidationPassed,
|
||||
wantRepairs: 1,
|
||||
wantCalls: 2,
|
||||
wantRaw: valid,
|
||||
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
|
||||
},
|
||||
{
|
||||
name: "invalid output repaired",
|
||||
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
|
||||
wantStatus: promptexec.ValidationPassed,
|
||||
wantRepairs: 1,
|
||||
wantCalls: 2,
|
||||
wantRaw: valid,
|
||||
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
|
||||
},
|
||||
{
|
||||
name: "repair budget exhausted",
|
||||
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(invalid, 7, 11, 18)}},
|
||||
wantStatus: promptexec.ValidationFailed,
|
||||
wantRepairs: 1,
|
||||
wantCalls: 2,
|
||||
wantRaw: invalid,
|
||||
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeClient{outcomes: test.outcomes}
|
||||
adapter := newRepairAdapter(t, client, "https://repair.example/v1")
|
||||
var preparation promptexec.Preparation
|
||||
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
|
||||
preparation = value
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if preparation.Output.RepairAttempts != 1 || result == nil || result.Validation.Status != test.wantStatus || result.Validation.RepairAttempts != test.wantRepairs || string(result.RawOutput) != test.wantRaw || result.Usage != test.wantUsage {
|
||||
t.Fatalf("preparation/result = %#v/%#v", preparation, result)
|
||||
}
|
||||
requests := client.allRequests()
|
||||
if len(requests) != test.wantCalls {
|
||||
t.Fatalf("provider requests = %d, want %d", len(requests), test.wantCalls)
|
||||
}
|
||||
if test.wantCalls == 2 && !reflect.DeepEqual(requests[0].Target, requests[1].Target) {
|
||||
t.Fatalf("corrective target = %#v, want same prepared identity as %#v", requests[1].Target, requests[0].Target)
|
||||
}
|
||||
if result.ProfileID != preparation.ProfileID || result.BackendID != preparation.BackendID || result.ModelName != preparation.ModelName || result.PromptID != preparation.PromptID || result.PromptVersion != preparation.PromptVersion || result.PromptHash != preparation.PromptHash {
|
||||
t.Fatalf("prepared/result identity = %#v/%#v", preparation, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMapsCorrectiveGenerationError(t *testing.T) {
|
||||
const providerBody = `{"error":{"code":"repair-code","type":"repair-type","message":"repair-message"}}`
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(writer, `{"choices":[{"message":{"content":%q}}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`, `{"summary":42}`)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = writer.Write([]byte(providerBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := newRepairAdapter(t, nil, server.URL)
|
||||
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), nil)
|
||||
if result != nil || err == nil || calls != 2 {
|
||||
t.Fatalf("result/error/calls = %#v/%v/%d", result, err, calls)
|
||||
}
|
||||
var generationError *promptexec.GenerationError
|
||||
if !errors.As(err, &generationError) || generationError.StatusCode() != http.StatusUnprocessableEntity || generationError.ProviderCode() != "repair-code" || generationError.ProviderType() != "repair-type" || generationError.ProviderMessage() != "repair-message" {
|
||||
t.Fatalf("generation error = %#v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "repair-message") || !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||
t.Fatalf("generation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -430,11 +782,12 @@ func TestNewValidatesConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||
profiles := testProfileDirectory(t, `id: local-profile
|
||||
func TestLocalBackendAndOptionalCredentialSourceBehavior(t *testing.T) {
|
||||
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
|
||||
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: local-profile
|
||||
backend: local
|
||||
model: local-model
|
||||
`)
|
||||
`})
|
||||
adapter, err := newAdapterForTest(Config{
|
||||
ProfileDirectory: profiles,
|
||||
LocalEndpoint: "https://local.example/v1",
|
||||
@@ -451,11 +804,11 @@ model: local-model
|
||||
t.Fatalf("capacity classification = %v", got)
|
||||
}
|
||||
|
||||
credentialProfiles := testProfileDirectory(t, `id: credential-profile
|
||||
credentialProfiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: credential-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||
`)
|
||||
`})
|
||||
client := &fakeClient{response: validResponse()}
|
||||
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
|
||||
if err != nil {
|
||||
@@ -468,8 +821,8 @@ api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||
request := testExecuteRequest()
|
||||
request.ProfileID = "credential-profile"
|
||||
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
|
||||
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
|
||||
if err != nil || result == nil || client.callCount() != 1 {
|
||||
t.Fatalf("credential result/error/calls = %#v/%v/%d", result, err, client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,14 +843,14 @@ func assertProfile(t *testing.T, adapter *Adapter, id string, backend string, mo
|
||||
|
||||
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
||||
t.Helper()
|
||||
profiles := testProfileDirectory(t, `id: test-profile
|
||||
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: test-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
temperature: 0.2
|
||||
max_tokens: 300
|
||||
top_p: 1
|
||||
timeout_seconds: 30
|
||||
`)
|
||||
`})
|
||||
options = append(options, promptkit.WithLLMClient(client))
|
||||
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
@@ -506,13 +859,15 @@ timeout_seconds: 30
|
||||
return adapter
|
||||
}
|
||||
|
||||
func testProfileDirectory(t *testing.T, profile string) string {
|
||||
func testProfileDirectory(t *testing.T, profiles map[string]string) string {
|
||||
t.Helper()
|
||||
profiles := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
directory := t.TempDir()
|
||||
for name, profile := range profiles {
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
return directory
|
||||
}
|
||||
|
||||
func writeProfileFile(t *testing.T, profile string) string {
|
||||
@@ -527,7 +882,7 @@ func writeProfileFile(t *testing.T, profile string) string {
|
||||
func testExecuteRequest() promptexec.ExecuteRequest {
|
||||
return promptexec.ExecuteRequest{
|
||||
PromptID: "weather.daily_generated_text",
|
||||
PromptVersion: "2.0.0",
|
||||
PromptVersion: "2.1.0",
|
||||
ProfileID: "test-profile",
|
||||
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||
}
|
||||
@@ -540,6 +895,61 @@ func validResponse() *promptkit.GenerateResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func generationResponse(content string, promptTokens int, completionTokens int, totalTokens int) *promptkit.GenerateResponse {
|
||||
return &promptkit.GenerateResponse{
|
||||
Content: content,
|
||||
Usage: promptkit.TokenUsage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: totalTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRepairAdapter(t *testing.T, client promptkit.LLMClient, endpoint string) *Adapter {
|
||||
t.Helper()
|
||||
profiles := testProfileDirectory(t, map[string]string{"profile.yml": "id: repair-profile\nendpoint: " + endpoint + "\nmodel: repair-model\n"})
|
||||
options := []promptkit.Option{
|
||||
promptkit.WithPromptFS(fstest.MapFS{
|
||||
"repair.yml": &fstest.MapFile{Data: []byte(`id: weather.repair
|
||||
version: "1.0.0"
|
||||
default_profile: repair-profile
|
||||
inputs:
|
||||
- name: data_package
|
||||
required: true
|
||||
content_type: application/yaml
|
||||
messages:
|
||||
- role: user
|
||||
content: "{{input \"data_package\"}}"
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: repair.schema.json
|
||||
repair_attempts: 1
|
||||
`)}}, "."),
|
||||
promptkit.WithSchemaFS(fstest.MapFS{
|
||||
"repair.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"],"additionalProperties":false}`)},
|
||||
}, "."),
|
||||
}
|
||||
if client != nil {
|
||||
options = append(options, promptkit.WithLLMClient(client))
|
||||
}
|
||||
adapter, err := newAdapter(Config{ProfileDirectory: profiles}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapter() error = %v", err)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
func repairExecuteRequest() promptexec.ExecuteRequest {
|
||||
return promptexec.ExecuteRequest{
|
||||
PromptID: "weather.repair",
|
||||
PromptVersion: "1.0.0",
|
||||
ProfileID: "repair-profile",
|
||||
DataPackage: []byte("report: repair\n"),
|
||||
}
|
||||
}
|
||||
|
||||
func hourlyValidResponse() *promptkit.GenerateResponse {
|
||||
return &promptkit.GenerateResponse{
|
||||
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -14,24 +15,28 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
currentConditionsEndpoint = "/conditions/current"
|
||||
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
|
||||
|
||||
defaultWarmupEndpoint = "/conditions/current"
|
||||
defaultWarmupEndpoint = currentConditionsEndpoint
|
||||
defaultWarmupAttempts = 3
|
||||
defaultWarmupDelay = time.Second
|
||||
defaultFetchAttempts = 2
|
||||
defaultFetchRetryDelay = time.Second
|
||||
maxResponseBodyBytes = 10 << 20
|
||||
)
|
||||
|
||||
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
@@ -75,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
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
|
||||
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) {
|
||||
if err := c.warmup(ctx); err != nil {
|
||||
warmup, err := c.warmup(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
}
|
||||
|
||||
if err := builder.fetchObservation(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchCurrent(ctx); 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
|
||||
for _, acquired := range builder.acquireSources(ctx, warmup) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("fetch weather API sources: %w", err)
|
||||
}
|
||||
if err := builder.mergeSource(acquired); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
}
|
||||
|
||||
type sourceRequest struct {
|
||||
@@ -165,14 +156,81 @@ type fetchedSource struct {
|
||||
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
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "observations",
|
||||
endpoint: "/observations",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "observation data is missing",
|
||||
}, &observation)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
@@ -183,14 +241,18 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "current",
|
||||
endpoint: "/conditions/current",
|
||||
func currentConditionsRequest() sourceRequest {
|
||||
return sourceRequest{
|
||||
name: config.MissingSourceCurrent,
|
||||
endpoint: currentConditionsEndpoint,
|
||||
query: queryOptions{precision: true},
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -200,16 +262,9 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
|
||||
var hourly weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "hourly",
|
||||
endpoint: "/forecast/hourly",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "hourly forecast data is missing",
|
||||
required: true,
|
||||
decodeLabel: "hourly forecast",
|
||||
}, &hourly)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
@@ -217,6 +272,14 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
if len(hourly.Periods) == 0 {
|
||||
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.UpdatedAt = hourly.UpdatedAt
|
||||
b.bundle.Hourly = &hourly
|
||||
@@ -224,14 +287,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
|
||||
var narrative weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "narrative",
|
||||
endpoint: "/forecast/narrative",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "narrative forecast data is missing",
|
||||
}, &narrative)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
@@ -243,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||
if err != nil {
|
||||
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
|
||||
fetched, ok, err := b.fetchSource(acquired)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
raw, source := fetched.raw, fetched.source
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts weatherdata.AlertRun
|
||||
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 {
|
||||
source.IssuedAt = alerts.AsOf
|
||||
}
|
||||
@@ -269,14 +324,9 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
|
||||
var discussion weatherdata.Discussion
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "discussion",
|
||||
endpoint: "/discussion",
|
||||
query: queryOptions{timezone: true},
|
||||
missingMessage: "forecast discussion data is missing",
|
||||
}, &discussion)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
@@ -288,18 +338,16 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
|
||||
var story weatherdata.WeatherStory
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "weather_story",
|
||||
endpoint: "/weatherstories/latest",
|
||||
query: queryOptions{omitUnits: true},
|
||||
missingMessage: "NWS weather story data is missing",
|
||||
}, &story)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
source := fetched.source
|
||||
if !story.HasUsableContent() {
|
||||
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
|
||||
}
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
}
|
||||
@@ -309,14 +357,9 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: sourceSPCConvectiveOutlooks,
|
||||
endpoint: convectiveOutlooksEndpoint,
|
||||
query: queryOptions{timezone: true, omitUnits: true},
|
||||
missingMessage: "SPC convective outlook data is missing",
|
||||
}, &run)
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &run)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
@@ -332,26 +375,35 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||
fetched, ok, err := b.fetchSource(ctx, request)
|
||||
func (b *bundleBuilder) fetchDecodedSource(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
|
||||
fetched, ok, err := b.fetchSource(acquired)
|
||||
if err != nil || !ok {
|
||||
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 {
|
||||
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||
}
|
||||
return fetched, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||
if err != nil {
|
||||
return fetchedSource{}, false, err
|
||||
func (b *bundleBuilder) fetchSource(acquired sourceAcquisition) (fetchedSource, bool, error) {
|
||||
if acquired.usesWarmup {
|
||||
raw, source, err := b.client.decodeSourceResponse(acquired.request.name, acquired.request.endpoint, acquired.request.query, acquired.warmup.requestURL, acquired.warmup.body, acquired.warmup.fetchedAt)
|
||||
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 {
|
||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||
if acquired.fetched.raw == nil {
|
||||
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 {
|
||||
@@ -422,7 +474,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
if err != nil {
|
||||
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
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
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,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
FetchedAt: fetchedAt,
|
||||
}
|
||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||
source.Missing = true
|
||||
@@ -446,53 +501,40 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
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
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
endpoint = defaultWarmupEndpoint
|
||||
}
|
||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||
var lastErr error
|
||||
var lastRetryable bool
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
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
|
||||
lastRetryable = isRetryableRequestError(err)
|
||||
} else {
|
||||
return nil
|
||||
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
|
||||
}
|
||||
if attempt == attempts {
|
||||
if !lastRetryable || attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||
return 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 {
|
||||
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
|
||||
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
body, err := readResponseBody(resp.Body)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
if ctx.Err() != nil {
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
return reqURL, nil, responseReadError(ctx, endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
|
||||
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
err error
|
||||
}
|
||||
@@ -659,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
sum := sha256.Sum256(compact.Bytes())
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,12 @@ import (
|
||||
"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) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, nil, &requested)
|
||||
@@ -80,8 +88,8 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
if len(requested) != len(wantPaths)+1 {
|
||||
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
||||
if len(requested) != len(wantPaths) {
|
||||
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||
}
|
||||
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
||||
@@ -91,6 +99,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
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") {
|
||||
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) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, nil, &requested)
|
||||
@@ -194,8 +390,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
const marker = "upstream-secret-marker"
|
||||
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)
|
||||
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") {
|
||||
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) {
|
||||
@@ -231,8 +431,8 @@ func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||
if bundle.Current == nil {
|
||||
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||
}
|
||||
if warmupCalls != 3 {
|
||||
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
||||
if warmupCalls != 2 {
|
||||
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls)
|
||||
}
|
||||
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||
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) {
|
||||
var hourlyCalls int
|
||||
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) {
|
||||
var hourlyCalls int
|
||||
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) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-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 {
|
||||
status int
|
||||
body string
|
||||
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 {
|
||||
t.Helper()
|
||||
fixtures := 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",
|
||||
}
|
||||
var requestedMu sync.Mutex
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
requestedMu.Lock()
|
||||
*requested = append(*requested, r.URL.String())
|
||||
requestedMu.Unlock()
|
||||
}
|
||||
if override, ok := overrides[r.URL.Path]; ok {
|
||||
if override.handler != nil {
|
||||
@@ -670,12 +1099,9 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
||||
_, _ = w.Write([]byte(override.body))
|
||||
return
|
||||
}
|
||||
name, ok := fixtures[r.URL.Path]
|
||||
if !ok {
|
||||
if !serveWeatherFixture(w, r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", name))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
@@ -706,6 +1132,14 @@ func fixedNow() time.Time {
|
||||
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 {
|
||||
for _, rawURL := range requested {
|
||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -66,6 +67,7 @@ type BatchRequest struct {
|
||||
type ModuleSnapshotRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
Identity briefing.PreparedIdentity
|
||||
}
|
||||
|
||||
type ReportFacts struct {
|
||||
@@ -87,6 +89,7 @@ type ReportResult struct {
|
||||
ModelName string
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
RepairAttempts *int
|
||||
LLMDebugPath string
|
||||
OutputPath string
|
||||
Notification *NotificationResult
|
||||
@@ -99,6 +102,7 @@ type BatchResult struct {
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Canceled int `json:"canceled,omitempty"`
|
||||
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
||||
Reports []BatchReportResult `json:"reports"`
|
||||
}
|
||||
@@ -136,18 +140,26 @@ type BatchReportResult struct {
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ValidationStatus promptexec.ValidationStatus `json:"validationStatus,omitempty"`
|
||||
RepairAttempts *int `json:"repairAttempts,omitempty"`
|
||||
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
}
|
||||
|
||||
type BatchError struct {
|
||||
Result *BatchResult
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e BatchError) Error() string {
|
||||
if e.Result == nil {
|
||||
return "batch failed"
|
||||
}
|
||||
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 != "" {
|
||||
@@ -158,6 +170,10 @@ func (e BatchError) Error() string {
|
||||
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 {
|
||||
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
|
||||
}
|
||||
@@ -249,6 +265,9 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
||||
return nil, err
|
||||
}
|
||||
result := initialReportResult(req, resolved, PromptInspectionResult{})
|
||||
if err := preflightDistributorNotification(req.Config); err != nil {
|
||||
return result, err
|
||||
}
|
||||
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, req.Config.Output.Directory, resolved)
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -258,6 +277,7 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
||||
if err != nil {
|
||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||
}
|
||||
defer func() { _ = debugWriter.Close() }()
|
||||
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
||||
Resolved: resolved,
|
||||
Executor: req.Executor,
|
||||
@@ -300,6 +320,9 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
@@ -309,6 +332,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
if err != nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||
}
|
||||
defer func() { _ = debugWriter.Close() }()
|
||||
candidates, err := batchInspectionCandidates(req, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -335,7 +359,12 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
||||
startedAt := now
|
||||
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
|
||||
item := batchReportResult(planned)
|
||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||
@@ -355,26 +384,72 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
copyBatchReportDetails(&item, reportResult)
|
||||
}
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
result.Failed++
|
||||
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
|
||||
cancellation = reportCancellation
|
||||
item.Status = "canceled"
|
||||
result.Canceled++
|
||||
} else {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
result.Failed++
|
||||
}
|
||||
} else {
|
||||
item.Status = "succeeded"
|
||||
result.Succeeded++
|
||||
}
|
||||
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)
|
||||
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, 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 {
|
||||
result.Notification = batchNotification
|
||||
}
|
||||
result.FinishedAt = time.Now()
|
||||
return result, nil
|
||||
return result, cancellation
|
||||
}
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
|
||||
func batchReportCancellationCause(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return context.Canceled
|
||||
}
|
||||
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
|
||||
@@ -384,6 +459,9 @@ func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
|
||||
item.Timezone = result.Timezone
|
||||
item.SourceWarnings = append([]weatherdata.SourceWarning(nil), result.SourceWarnings...)
|
||||
item.ValidationStatus = result.ValidationStatus
|
||||
if result.RepairAttempts != nil {
|
||||
item.RepairAttempts = repairAttemptsPointer(*result.RepairAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||
@@ -509,6 +587,16 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func preflightDistributorNotification(cfg config.Config) error {
|
||||
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 {
|
||||
@@ -631,25 +719,7 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
|
||||
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
notification := &NotificationResult{
|
||||
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
|
||||
}
|
||||
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
|
||||
if err != nil {
|
||||
return notification, err
|
||||
}
|
||||
@@ -673,7 +743,7 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
UploadStatus: result.UploadStatus,
|
||||
StatusError: result.StatusError,
|
||||
StatusError: safeDistributorStatusError(result.StatusError),
|
||||
}
|
||||
if result.RunStatus != nil {
|
||||
if result.RunStatus.PipelineID != "" {
|
||||
@@ -682,12 +752,25 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
||||
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
|
||||
notification.Error = safeDistributorRunError(result.RunStatus.Error)
|
||||
}
|
||||
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 {
|
||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||
for _, bundlePath := range bundlePaths {
|
||||
@@ -727,7 +810,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
identity := req.Identity
|
||||
if identity.ReportID == "" {
|
||||
identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||
}
|
||||
moduleContext := briefing.ModuleContext{
|
||||
Identity: identity,
|
||||
Resolved: req.Resolved,
|
||||
Collected: reportFacts.Collected,
|
||||
Derived: reportFacts.Derived,
|
||||
@@ -759,16 +847,16 @@ func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected
|
||||
}
|
||||
}
|
||||
|
||||
func promptMetadata(metadata briefing.Metadata) promptinput.Metadata {
|
||||
func promptMetadata(identity briefing.PreparedIdentity) promptinput.Metadata {
|
||||
return promptinput.Metadata{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: metadata.Variant,
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: metadata.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
SourceWarnings: metadata.SourceWarnings,
|
||||
RunID: identity.RunID,
|
||||
ReportID: identity.ReportID,
|
||||
Variant: identity.Variant,
|
||||
PromptID: identity.PromptID,
|
||||
GeneratedAt: identity.GeneratedAt,
|
||||
Timezone: identity.Timezone,
|
||||
ValidPeriod: identity.ValidPeriod,
|
||||
SourceWarnings: identity.SourceWarnings,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
||||
@@ -21,7 +22,7 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
||||
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 != "" {
|
||||
@@ -32,6 +33,107 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -61,6 +163,29 @@ func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -42,46 +42,64 @@ type batchNotifier interface {
|
||||
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 {
|
||||
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, notifier Notifier) *BatchNotificationResult {
|
||||
if !cfg.Notify.Distributor.Enabled {
|
||||
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
|
||||
if !input.cfg.Notify.Distributor.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||
if !input.cfg.Notify.Distributor.Batch.Enabled {
|
||||
return nil
|
||||
}
|
||||
if result == nil {
|
||||
if input.result == nil {
|
||||
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{
|
||||
Status: "skipped",
|
||||
Reason: "one or more reports failed",
|
||||
}
|
||||
}
|
||||
|
||||
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||
}
|
||||
|
||||
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(req, err)
|
||||
}
|
||||
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
|
||||
wrappedErr := notifyErr
|
||||
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)
|
||||
}
|
||||
batchResult := batchNotificationResult(req, notification)
|
||||
if wrappedErr != nil {
|
||||
batchResult.Status = "failed"
|
||||
batchResult.Error = wrappedErr.Error()
|
||||
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
|
||||
return batchResult
|
||||
}
|
||||
return batchResult
|
||||
@@ -233,7 +251,7 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
|
||||
notification.IdempotencyKey = result.IdempotencyKey
|
||||
}
|
||||
if result.Error != "" {
|
||||
notification.Error = result.Error
|
||||
notification.Error = safeDistributorRunError(result.Error)
|
||||
}
|
||||
}
|
||||
if notification.Status == "" {
|
||||
@@ -246,11 +264,18 @@ func failedBatchNotificationResult(req batchNotificationRequest, err error) *Bat
|
||||
notification := batchNotificationResult(req, nil)
|
||||
notification.Status = "failed"
|
||||
if err != nil {
|
||||
notification.Error = err.Error()
|
||||
notification.Error = safeDistributorNotificationFailure(err)
|
||||
}
|
||||
return notification
|
||||
}
|
||||
|
||||
func safeDistributorNotificationFailure(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return "distributor notification failed"
|
||||
}
|
||||
|
||||
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -60,6 +59,7 @@ type ComparisonProfileResult struct {
|
||||
ModelName string
|
||||
Status string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
RepairAttempts *int
|
||||
ReportPath string
|
||||
LLMDebugPath string
|
||||
Error *comparison.SafeError
|
||||
@@ -110,7 +110,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
||||
return result, err
|
||||
}
|
||||
result.OutputDirectory = outputDirectory
|
||||
_, err = comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("preflight comparison destination: %w", err)
|
||||
}
|
||||
@@ -119,8 +119,9 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
||||
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,
|
||||
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor,
|
||||
})
|
||||
result.PromptID, result.PromptVersion, result.PromptHash = inspection.PromptID, inspection.PromptVersion, inspection.PromptHash
|
||||
if err != nil {
|
||||
@@ -131,7 +132,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection})
|
||||
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)
|
||||
}
|
||||
@@ -149,10 +150,6 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
||||
if err := bundle.Validate(); err != nil {
|
||||
return result, fmt.Errorf("build comparison bundle: %w", err)
|
||||
}
|
||||
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("re-preflight comparison destination: %w", err)
|
||||
}
|
||||
publication, err := publish(ctx, publicationPlan, bundle)
|
||||
if publication.Committed {
|
||||
result.OutputDirectory = publicationPlan.Target
|
||||
@@ -201,6 +198,9 @@ func copyComparisonOutcomes(result *ComparisonResult, outcomes []comparisonProfi
|
||||
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 outcome.RepairAttempts != nil {
|
||||
profile.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
|
||||
}
|
||||
if published && outcome.Status == comparison.StatusSucceeded {
|
||||
profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath)
|
||||
}
|
||||
@@ -230,6 +230,9 @@ func comparisonBundle(result *ComparisonResult, dataPackage []byte, outcomes []c
|
||||
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
|
||||
Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error,
|
||||
}
|
||||
if outcome.RepairAttempts != nil {
|
||||
manifestResult.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
|
||||
}
|
||||
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})
|
||||
|
||||
@@ -31,15 +31,26 @@ type comparisonProfileOutcome struct {
|
||||
ModelName string
|
||||
Status string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
RepairAttempts *int
|
||||
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,
|
||||
@@ -54,21 +65,22 @@ func executeComparisonProfiles(ctx context.Context, req comparisonExecutionReque
|
||||
for index, profile := range profiles {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Canceled = true
|
||||
markUnstartedComparisonOutcomes(result.Outcomes[index:], err)
|
||||
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 result.Outcomes[index].Status != comparison.StatusSucceeded {
|
||||
if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
|
||||
markCanceledComparisonOutcome(&result.Outcomes[index], err)
|
||||
}
|
||||
}
|
||||
@@ -97,8 +109,12 @@ func executeComparisonProfile(ctx context.Context, req comparisonExecutionReques
|
||||
})
|
||||
outcome.ProfileID, outcome.BackendID, outcome.ModelName = execution.ProfileID, execution.BackendID, execution.ModelName
|
||||
outcome.ValidationStatus = execution.ValidationStatus
|
||||
if execution.RepairAttempts != nil {
|
||||
outcome.RepairAttempts = repairAttemptsPointer(*execution.RepairAttempts)
|
||||
}
|
||||
outcome.LLMDebugPath = execution.LLMDebugPath
|
||||
if err != nil {
|
||||
outcome.canceled = cancellationError(err)
|
||||
safe := comparisonSafeExecutionError(err)
|
||||
outcome.Error = &safe
|
||||
return outcome
|
||||
@@ -119,12 +135,6 @@ func comparisonDebugRunID(comparisonID string, position, profileCount int, profi
|
||||
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
|
||||
}
|
||||
|
||||
func markUnstartedComparisonOutcomes(outcomes []comparisonProfileOutcome, err error) {
|
||||
for index := range outcomes {
|
||||
markCanceledComparisonOutcome(&outcomes[index], err)
|
||||
}
|
||||
}
|
||||
|
||||
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
|
||||
outcome.Status = comparison.StatusFailed
|
||||
outcome.ValidationStatus = promptexec.ValidationSkipped
|
||||
@@ -134,6 +144,12 @@ func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error)
|
||||
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 == "" {
|
||||
@@ -157,9 +173,14 @@ func comparisonExecutionMessage(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "profile execution deadline exceeded"
|
||||
}
|
||||
operation := "profile execution"
|
||||
var execution *profileExecutionError
|
||||
if errors.As(err, &execution) {
|
||||
return comparison.TruncateErrorMessage(execution.operation + " failed")
|
||||
operation = execution.operation
|
||||
}
|
||||
return "profile execution failed"
|
||||
var generation *promptexec.GenerationError
|
||||
if errors.As(err, &generation) && generation.StatusCode() > 0 {
|
||||
return comparison.TruncateErrorMessage(fmt.Sprintf("%s failed (HTTP %d)", operation, generation.StatusCode()))
|
||||
}
|
||||
return comparison.TruncateErrorMessage(operation + " failed")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -20,12 +22,9 @@ func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T)
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(10)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
results := make(chan comparisonExecutionResult, 1)
|
||||
go func() {
|
||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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())
|
||||
@@ -58,12 +57,9 @@ func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
|
||||
profiles := comparisonProfiles(3)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
|
||||
results := make(chan comparisonExecutionResult, 1)
|
||||
go func() {
|
||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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)
|
||||
@@ -78,18 +74,75 @@ func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteComparisonProfilesPreservesIndependentRepairOutcomes(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(4)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
executor.setValidation(profiles[0].ProfileID, promptexec.ValidationPassed, 0)
|
||||
executor.setValidation(profiles[1].ProfileID, promptexec.ValidationPassed, 1)
|
||||
executor.setValidation(profiles[2].ProfileID, promptexec.ValidationFailed, 1)
|
||||
executor.setError(profiles[3].ProfileID, errors.New("provider failure"))
|
||||
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
}, executor)
|
||||
waitForProfileStarts(t, executor, profiles, results)
|
||||
executor.releaseAll()
|
||||
result := <-results
|
||||
wantStatuses := []string{comparison.StatusSucceeded, comparison.StatusSucceeded, comparison.StatusFailed, comparison.StatusFailed}
|
||||
wantValidations := []promptexec.ValidationStatus{promptexec.ValidationPassed, promptexec.ValidationPassed, promptexec.ValidationFailed, ""}
|
||||
wantRepairs := []*int{intPointer(0), intPointer(1), intPointer(1), nil}
|
||||
for index, outcome := range result.Outcomes {
|
||||
if outcome.Status != wantStatuses[index] || outcome.ValidationStatus != wantValidations[index] || !reflect.DeepEqual(outcome.RepairAttempts, wantRepairs[index]) {
|
||||
t.Fatalf("outcome[%d] = %#v, want status/validation/repairs %q/%q/%#v", index, outcome, wantStatuses[index], wantValidations[index], wantRepairs[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteComparisonProfilesCapturesConcurrentProviderFailures(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(2)
|
||||
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)
|
||||
}
|
||||
markers := []string{"first-provider-private-marker", "second-provider-private-marker"}
|
||||
statuses := []int{http.StatusTooManyRequests, http.StatusServiceUnavailable}
|
||||
executor := newBarrierExecutor(profiles)
|
||||
for index, profile := range profiles {
|
||||
executor.setError(profile.ProfileID, promptexec.NewGenerationError(statuses[index], "provider_code", "provider_type", markers[index], nil))
|
||||
}
|
||||
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)
|
||||
executor.releaseAll()
|
||||
result := <-results
|
||||
for index, outcome := range result.Outcomes {
|
||||
if outcome.Status != comparison.StatusFailed || outcome.Error == nil || outcome.Error.Category != string(promptexec.Generation) || outcome.Error.Message != fmt.Sprintf("execute prompt failed (HTTP %d)", statuses[index]) || strings.Contains(outcome.Error.Message, markers[index]) || outcome.LLMDebugPath == "" {
|
||||
t.Fatalf("outcome[%d] = %#v", index, outcome)
|
||||
}
|
||||
failure, readErr := os.ReadFile(filepath.Join(outcome.LLMDebugPath, "failure.json"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if !strings.Contains(string(failure), markers[index]) || strings.Contains(string(failure), markers[1-index]) {
|
||||
t.Fatalf("failure[%d] = %s", index, 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 := make(chan comparisonExecutionResult, 1)
|
||||
go func() {
|
||||
results <- executeComparisonProfiles(ctx, comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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
|
||||
@@ -110,16 +163,16 @@ func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *te
|
||||
{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 := make(chan comparisonExecutionResult, 1)
|
||||
go func() {
|
||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
||||
})
|
||||
}()
|
||||
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)
|
||||
@@ -148,18 +201,23 @@ type barrierExecutor struct {
|
||||
releases map[string]chan struct{}
|
||||
requests map[string]promptexec.ExecuteRequest
|
||||
errors map[string]error
|
||||
validations map[string]promptexec.ValidationStatus
|
||||
repairAttempts map[string]int
|
||||
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{},
|
||||
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{}, validations: map[string]promptexec.ValidationStatus{}, repairAttempts: map[string]int{}, profiles: identities,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +231,11 @@ func (e *barrierExecutor) InspectProfile(context.Context, string) (promptexec.Pr
|
||||
|
||||
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)
|
||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", ProfileID: req.ProfileID, BackendID: "backend-" + req.ProfileID, ModelName: "model-" + req.ProfileID, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
e.mu.Lock()
|
||||
profile := e.profiles[req.ProfileID]
|
||||
e.mu.Unlock()
|
||||
definition := generationDefinitionForPrompt(req.PromptID)
|
||||
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: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts}, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
e.callbackFailures <- err
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,15 +259,20 @@ func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteReq
|
||||
e.mu.Lock()
|
||||
e.inFlight--
|
||||
err := e.errors[req.ProfileID]
|
||||
validationStatus := e.validations[req.ProfileID]
|
||||
repairAttempts := e.repairAttempts[req.ProfileID]
|
||||
e.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validationStatus == "" {
|
||||
validationStatus = promptexec.ValidationPassed
|
||||
}
|
||||
return &promptexec.Execution{
|
||||
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||
ProfileID: req.ProfileID, BackendID: "backend-" + req.ProfileID, ModelName: "model-" + req.ProfileID,
|
||||
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),
|
||||
Validation: promptexec.NewValidation(validationStatus, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", repairAttempts, nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -222,6 +289,13 @@ func (e *barrierExecutor) setError(profileID string, err error) {
|
||||
e.errors[profileID] = err
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) setValidation(profileID string, status promptexec.ValidationStatus, repairAttempts int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.validations[profileID] = status
|
||||
e.repairAttempts[profileID] = repairAttempts
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) release(profileID string) {
|
||||
close(e.releases[profileID])
|
||||
}
|
||||
@@ -248,9 +322,32 @@ func (e *barrierExecutor) inFlightCount() int {
|
||||
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(5 * time.Second)
|
||||
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||
defer timeout.Stop()
|
||||
seen := map[string]struct{}{}
|
||||
for range profiles {
|
||||
@@ -297,4 +394,8 @@ func bytesEqual(left, right []byte) bool {
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func intPointer(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
var _ promptexec.Executor = (*barrierExecutor)(nil)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -86,28 +87,77 @@ func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
||||
func TestCompareDetailedPublishesPostValidationProfileFailure(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
backupPath := filepath.Join(t.TempDir(), ".comparison-daily.backup-retained")
|
||||
cleanupCause := errors.New("backup cleanup failed")
|
||||
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
|
||||
return comparison.PublicationResult{Committed: true, RetainedBackupPath: backupPath}, &comparison.PublicationCleanupError{RetainedBackupPath: backupPath, Err: cleanupCause}
|
||||
}
|
||||
|
||||
result, err := compareDetailed(context.Background(), ComparisonRequest{
|
||||
executor := &generationExecutor{complete: func(execution *promptexec.Execution) {
|
||||
if execution.ProfileID == "weather-deep" {
|
||||
execution.RawOutput = []byte(`{"summary":42}`)
|
||||
}
|
||||
}}
|
||||
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.RetainedBackupPath != backupPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || result.Succeeded != 1 || result.Failed != 1 || result.ManifestPath == "" {
|
||||
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)
|
||||
}
|
||||
failure := result.Results[1]
|
||||
if failure.Status != comparison.StatusFailed || failure.ValidationStatus != promptexec.ValidationPassed || failure.RepairAttempts == nil || *failure.RepairAttempts != 0 {
|
||||
t.Fatalf("post-validation failure = %#v", failure)
|
||||
}
|
||||
data, readErr := os.ReadFile(result.ManifestPath)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var manifest comparison.Manifest
|
||||
if decodeErr := json.Unmarshal(data, &manifest); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
manifestFailure := manifest.Results[1]
|
||||
if manifestFailure.ValidationStatus != "passed" || manifestFailure.RepairAttempts == nil || *manifestFailure.RepairAttempts != 0 {
|
||||
t.Fatalf("published post-validation failure = %#v", manifestFailure)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +342,57 @@ func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,20 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,25 @@ type generationCollector struct {
|
||||
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()
|
||||
@@ -46,8 +69,15 @@ type generationExecutor struct {
|
||||
beforeExecute func(promptexec.ExecuteRequest)
|
||||
cancelBeforeReturn context.CancelFunc
|
||||
validation promptexec.ValidationStatus
|
||||
repairAttempts int
|
||||
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
|
||||
@@ -60,7 +90,7 @@ func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string
|
||||
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
|
||||
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", RepairAttempts: definition.GeneratedTextRepairAttempts}}, nil
|
||||
}
|
||||
func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
generationExecutorMu.Lock()
|
||||
@@ -71,10 +101,28 @@ func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promp
|
||||
}
|
||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||
}
|
||||
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
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)
|
||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
return nil, err
|
||||
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 {
|
||||
definition := generationDefinitionForPrompt(req.PromptID)
|
||||
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: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts}, StartedAt: stamp, EndedAt: stamp}
|
||||
if prepare != nil {
|
||||
prepare(&preparation)
|
||||
}
|
||||
if err := callback(preparation, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
generationExecutorMu.Lock()
|
||||
e.called = true
|
||||
@@ -83,13 +131,23 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
|
||||
profileErr := e.executeErrors[req.ProfileID]
|
||||
executeErr := e.executeErr
|
||||
status := e.validation
|
||||
repairAttempts := e.repairAttempts
|
||||
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
|
||||
}
|
||||
@@ -108,7 +166,11 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
|
||||
if cancelBeforeReturn != nil {
|
||||
cancelBeforeReturn()
|
||||
}
|
||||
return &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)}, nil
|
||||
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", repairAttempts, nil)}
|
||||
if complete != nil {
|
||||
complete(execution)
|
||||
}
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
@@ -250,6 +312,48 @@ func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -338,6 +442,41 @@ func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePub
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -360,10 +499,33 @@ func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
|
||||
if err := os.Mkdir(outputPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}})
|
||||
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() {
|
||||
t.Fatalf("GenerateDetailed() result/error/output-info = %#v/%v/%#v (%v)", result, err, info, statErr)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +537,9 @@ func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -385,6 +550,66 @@ func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedCapturesProviderFailureOnlyInDebugArtifacts(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
debugRoot := t.TempDir()
|
||||
const marker = "provider-private-generation-marker"
|
||||
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{executeErr: promptexec.NewGenerationError(http.StatusTooManyRequests, "rate_limit", "provider_error", marker, nil)},
|
||||
})
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err == nil || result == nil || result.LLMDebugPath == "" || promptexec.CategoryOf(err) != promptexec.Generation || !strings.Contains(err.Error(), "HTTP 429") || strings.Contains(err.Error(), marker) || result.OutputPath != "" {
|
||||
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, name := range []string{"preparation.json", "failure.json"} {
|
||||
if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil {
|
||||
t.Fatalf("debug artifact %q: %v", name, statErr)
|
||||
}
|
||||
}
|
||||
data, readErr := os.ReadFile(filepath.Join(result.LLMDebugPath, "failure.json"))
|
||||
if readErr != nil || !strings.Contains(string(data), marker) {
|
||||
t.Fatalf("failure artifact = %q, error = %v", data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesProviderFailureWhenFailureDebugWriteFails(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
debugRoot := t.TempDir()
|
||||
const marker = "provider-private-write-failure-marker"
|
||||
var setupErr error
|
||||
executor := &generationExecutor{
|
||||
executeErr: promptexec.NewGenerationError(http.StatusServiceUnavailable, "unavailable", "provider_error", marker, nil),
|
||||
beforeExecute: func(promptexec.ExecuteRequest) {
|
||||
setupErr = filepath.Walk(debugRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Name() == "preparation.json" {
|
||||
return os.Mkdir(filepath.Join(filepath.Dir(path), "failure.json"), 0o700)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
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: executor,
|
||||
})
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
var generationError *promptexec.GenerationError
|
||||
if setupErr != nil || err == nil || result == nil || result.OutputPath != "" || promptexec.CategoryOf(err) != promptexec.Generation || !errors.As(err, &generationError) || generationError.StatusCode() != http.StatusServiceUnavailable || !strings.Contains(err.Error(), "HTTP 503") || strings.Contains(err.Error(), marker) {
|
||||
t.Fatalf("GenerateDetailed() setup/result/error = %v/%#v/%v", setupErr, result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func generationConfig() config.Config {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
@@ -184,10 +185,8 @@ func validateOutputPath(path string) (string, error) {
|
||||
if filepath.Dir(path) == path {
|
||||
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return "", fmt.Errorf("final output path %q is a directory", path)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
)
|
||||
|
||||
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||
@@ -46,9 +48,7 @@ func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
dangling := filepath.Join(workingDir, "dangling")
|
||||
if err := os.Symlink(filepath.Join(workingDir, "missing"), dangling); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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) {
|
||||
@@ -63,9 +63,7 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(workingDir, "linked")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.RequireSymlink(t, target, link)
|
||||
|
||||
directory := filepath.Join(link, "reports")
|
||||
got, err := resolveOutputDir(workingDir, directory)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
@@ -17,19 +18,20 @@ import (
|
||||
// preparedReport contains the immutable deterministic inputs shared by prompt
|
||||
// executions for one resolved report.
|
||||
type preparedReport struct {
|
||||
resolved report.Resolved
|
||||
reportFacts ReportFacts
|
||||
moduleSnapshot module.Snapshot
|
||||
briefingMetadata briefing.Metadata
|
||||
sourceWarnings []weatherdata.SourceWarning
|
||||
dataPackage []byte
|
||||
handler generatedtext.Handler
|
||||
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 {
|
||||
@@ -54,12 +56,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
|
||||
}
|
||||
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts)
|
||||
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}
|
||||
}
|
||||
metadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot})
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "build data package", err: err}
|
||||
}
|
||||
@@ -67,31 +70,26 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
|
||||
}
|
||||
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err}
|
||||
}
|
||||
|
||||
clonedFacts, err := clonePreparedValue(reportFacts)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared report facts", err: err}
|
||||
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}
|
||||
}
|
||||
clonedMetadata, err := clonePreparedValue(metadata)
|
||||
clonedIdentity, err := clonePreparedValue(identity)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err}
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
|
||||
}
|
||||
prepared := preparedReport{
|
||||
resolved: cloneResolved(req.Resolved),
|
||||
reportFacts: clonedFacts,
|
||||
moduleSnapshot: clonedSnapshot,
|
||||
briefingMetadata: clonedMetadata,
|
||||
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...),
|
||||
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||
handler: handler,
|
||||
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
|
||||
}
|
||||
@@ -119,20 +117,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
|
||||
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
|
||||
}
|
||||
|
||||
func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) {
|
||||
metadata, err := clonePreparedValue(p.briefingMetadata)
|
||||
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
|
||||
identity, err := clonePreparedValue(p.identity)
|
||||
if err != nil {
|
||||
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
snapshot, err := clonePreparedValue(p.moduleSnapshot)
|
||||
if err != nil {
|
||||
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
reportFacts, err := clonePreparedValue(p.reportFacts)
|
||||
derived, err := clonePreparedValue(p.derived)
|
||||
if err != nil {
|
||||
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
return metadata, snapshot, reportFacts, nil
|
||||
return identity, snapshot, derived, nil
|
||||
}
|
||||
|
||||
func clonePreparedValue[T any](value T) (T, error) {
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -20,7 +24,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}}
|
||||
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)
|
||||
@@ -29,20 +33,21 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("second prepareReport() error = %v", err)
|
||||
}
|
||||
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) {
|
||||
t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata)
|
||||
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...)
|
||||
originalMetadata := prepared.briefingMetadata
|
||||
originalIdentity := prepared.identity
|
||||
originalDerived := prepared.derived
|
||||
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
|
||||
metadata, snapshot, reportFacts, err := prepared.renderInputs()
|
||||
identity, snapshot, derived, err := prepared.renderInputs()
|
||||
if err != nil {
|
||||
t.Fatalf("renderInputs() error = %v", err)
|
||||
}
|
||||
metadata.SourceWarnings = append(metadata.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
|
||||
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
|
||||
snapshot.Outputs = nil
|
||||
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation"
|
||||
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 {
|
||||
@@ -52,13 +57,65 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
||||
bundle.Sources[0].Query["mutated"] = "true"
|
||||
}
|
||||
|
||||
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
|
||||
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 prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" {
|
||||
t.Fatal("prepared report facts retain caller-owned weather data")
|
||||
}
|
||||
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "consumer mutation" || len(prepared.moduleSnapshot.Outputs) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
@@ -22,6 +25,7 @@ type profileExecutionOutcome struct {
|
||||
BackendID string
|
||||
ModelName string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
RepairAttempts *int
|
||||
LLMDebugPath string
|
||||
}
|
||||
|
||||
@@ -48,10 +52,22 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
||||
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
|
||||
preparationCallback := func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
outcome.ProfileID, outcome.BackendID, outcome.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
|
||||
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
|
||||
}
|
||||
@@ -59,7 +75,7 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
||||
callbackFailed = true
|
||||
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
|
||||
}
|
||||
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, preparation, debug)
|
||||
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, value, debug)
|
||||
if err != nil {
|
||||
callbackFailed = true
|
||||
return promptDebugWriteError(err)
|
||||
@@ -80,13 +96,34 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
||||
if callbackFailed {
|
||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: err, callbackFailure: true}
|
||||
}
|
||||
if req.DebugWriter != nil && req.DebugWriter.Enabled() && req.DebugRef != nil {
|
||||
var generationError *promptexec.GenerationError
|
||||
if errors.As(err, &generationError) {
|
||||
path, debugErr := req.DebugWriter.WriteFailure(*req.DebugRef, generationError)
|
||||
if path != "" {
|
||||
outcome.LLMDebugPath = path
|
||||
}
|
||||
if debugErr != nil {
|
||||
err = errors.Join(err, promptDebugWriteError(debugErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
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)}
|
||||
}
|
||||
|
||||
outcome.RepairAttempts = repairAttemptsPointer(execution.Validation.RepairAttempts)
|
||||
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"))}
|
||||
@@ -107,15 +144,15 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
||||
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)
|
||||
generatedText, err := req.Prepared.handler.Validate(execution.RawOutput)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||
}
|
||||
metadata, snapshot, reportFacts, err := req.Prepared.renderInputs()
|
||||
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(metadata, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText)
|
||||
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
|
||||
}
|
||||
@@ -125,3 +162,61 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
||||
}
|
||||
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.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" ||
|
||||
execution.Validation.RepairAttempts < 0 || execution.Validation.RepairAttempts > preparation.Output.RepairAttempts ||
|
||||
preparation.Output.RepairAttempts != definition.GeneratedTextRepairAttempts {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func repairAttemptsPointer(value int) *int {
|
||||
copy := value
|
||||
return ©
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -24,7 +26,7 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("executePreparedProfile() error = %v", err)
|
||||
}
|
||||
if len(rendered) == 0 || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ProfileID != inspection.ProfileID || executor.executeCalls != 1 {
|
||||
if len(rendered) == 0 || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.RepairAttempts == nil || *outcome.RepairAttempts != 0 || 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) {
|
||||
@@ -32,9 +34,26 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePreparedProfileRetainsCompletedRepairAttemptsOnLaterFailure(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
prepared.resolved.Definition.GeneratedTextRepairAttempts = 1
|
||||
executor := &generationExecutor{repairAttempts: 1, rawOutput: []byte(`{"summary":42}`), prepare: func(value *promptexec.Preparation) { value.Output.RepairAttempts = 1 }}
|
||||
outcome, _, 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 || outcome.RepairAttempts == nil || *outcome.RepairAttempts != 1 {
|
||||
t.Fatalf("outcome/error = %#v/%v", outcome, err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -51,6 +70,107 @@ func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -62,9 +182,9 @@ func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult)
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
bundle := generationBundle(t)
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}})
|
||||
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: "prompt-hash", ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
|
||||
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: generationPromptHash, ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report
|
||||
if result == nil {
|
||||
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
|
||||
}
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection})
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
|
||||
if err != nil {
|
||||
return result, generatedPreparationError(req.Resolved, result.RunID, err)
|
||||
}
|
||||
@@ -48,6 +48,9 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report
|
||||
})
|
||||
result.ProfileID, result.BackendID, result.ModelName = outcome.ProfileID, outcome.BackendID, outcome.ModelName
|
||||
result.ValidationStatus = outcome.ValidationStatus
|
||||
if outcome.RepairAttempts != nil {
|
||||
result.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
|
||||
}
|
||||
result.LLMDebugPath = outcome.LLMDebugPath
|
||||
if err != nil {
|
||||
return result, generatedProfileExecutionError(req.Resolved, result.RunID, err)
|
||||
@@ -86,7 +89,10 @@ func publishPromptReport(ctx context.Context, req promptPublicationRequest) (*Re
|
||||
if err := publicationContextError(ctx); err != nil {
|
||||
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(req.OutputPath, req.Markdown); err != nil {
|
||||
if err := fileutil.WriteFileAtomicContext(ctx, req.OutputPath, req.Markdown); err != nil {
|
||||
if contextErr := publicationContextError(ctx); contextErr != nil {
|
||||
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", contextErr)
|
||||
}
|
||||
return req.Result, err
|
||||
}
|
||||
req.Result.OutputPath = req.OutputPath
|
||||
|
||||
@@ -2,11 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"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/report"
|
||||
)
|
||||
@@ -17,7 +17,6 @@ type PromptInspectionRequest struct {
|
||||
Resolved report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// PromptInspectionResult contains only safe identity and provenance from a
|
||||
@@ -29,6 +28,7 @@ type PromptInspectionResult struct {
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||
@@ -37,7 +37,6 @@ type PromptExecutionsInspectionRequest struct {
|
||||
Resolved []report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// ComparisonInspectionRequest contains the explicit profile selection for one
|
||||
@@ -46,7 +45,6 @@ type ComparisonInspectionRequest struct {
|
||||
Resolved report.Resolved
|
||||
ProfileIDs []string
|
||||
Executor promptexec.Executor
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// ComparisonInspectionResult contains the safe, shared prompt identity and
|
||||
@@ -56,6 +54,7 @@ type ComparisonInspectionResult struct {
|
||||
PromptVersion string
|
||||
PromptHash string
|
||||
Profiles []ComparisonProfileInspection
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
// ComparisonProfileInspection contains one requested profile's safe effective
|
||||
@@ -73,7 +72,6 @@ func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (P
|
||||
Resolved: []report.Resolved{req.Resolved},
|
||||
Executor: req.Executor,
|
||||
Promptkit: req.Promptkit,
|
||||
LookupEnv: req.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, err
|
||||
@@ -91,6 +89,10 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
||||
profiles := map[string]promptexec.ProfileInspection{}
|
||||
for _, resolved := range req.Resolved {
|
||||
definition := resolved.Definition
|
||||
handler, err := generatedtext.LookupDefinition(definition)
|
||||
if err != nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
|
||||
}
|
||||
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -104,7 +106,7 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
||||
}
|
||||
profile, ok := profiles[profileID]
|
||||
if !ok {
|
||||
profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||
profile, err = inspectPromptProfile(ctx, req.Executor, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,6 +115,7 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
||||
results[definition.ID] = PromptInspectionResult{
|
||||
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
@@ -130,6 +133,10 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
|
||||
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)
|
||||
@@ -139,9 +146,10 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
|
||||
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)
|
||||
profile, err := inspectPromptProfile(ctx, req.Executor, profileID)
|
||||
if err != nil {
|
||||
return result, comparisonInspectionError("comparison profile inspection failed", err)
|
||||
}
|
||||
@@ -165,6 +173,9 @@ func inspectPromptContract(ctx context.Context, executor promptexec.Executor, de
|
||||
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)
|
||||
}
|
||||
@@ -174,7 +185,7 @@ func inspectPromptContract(ctx context.Context, executor promptexec.Executor, de
|
||||
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) (promptexec.ProfileInspection, error) {
|
||||
profile, err := executor.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
|
||||
@@ -185,14 +196,8 @@ func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, pro
|
||||
if profile.CredentialRequired {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
value, present := lookupEnv(profile.APIKeyEnv)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.ModelName) == "" {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return a complete execution identity", nil)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -202,7 +207,7 @@ func validPromptInput(inputs []promptexec.InputDefinition) bool {
|
||||
}
|
||||
|
||||
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
|
||||
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json"
|
||||
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json" && output.RepairAttempts == definition.GeneratedTextRepairAttempts
|
||||
}
|
||||
|
||||
func promptInspectionError(operation string, err error) error {
|
||||
|
||||
@@ -50,7 +50,6 @@ func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.
|
||||
name string
|
||||
prompt promptexec.PromptInspection
|
||||
profile promptexec.ProfileInspection
|
||||
lookupEnv func(string) (string, bool)
|
||||
wantCategory promptexec.ErrorCategory
|
||||
}{
|
||||
{
|
||||
@@ -71,24 +70,32 @@ func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "missing prompt hash",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.PromptHash = ""
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "missing profile model",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", BackendID: "backend"},
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "direct key",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
{
|
||||
name: "missing environment credential",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
|
||||
lookupEnv: func(string) (string, bool) { return "", false },
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
|
||||
}
|
||||
@@ -110,9 +117,7 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||
|
||||
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||
first := inspectionResolved(t)
|
||||
second := first
|
||||
second.Definition.ID = report.Today
|
||||
second.Definition.PromptID = "weather.today"
|
||||
second := inspectionResolvedFor(t, report.Today)
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(first.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
@@ -132,6 +137,67 @@ 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{
|
||||
@@ -191,13 +257,12 @@ func TestInspectComparisonExecutionStopsAtFirstProfileFailure(t *testing.T) {
|
||||
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"},
|
||||
"missing-key": {ProfileID: "missing-key", CredentialRequired: true},
|
||||
"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))
|
||||
@@ -265,12 +330,16 @@ func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest,
|
||||
}
|
||||
|
||||
func inspectionResolved(t *testing.T) report.Resolved {
|
||||
return inspectionResolvedFor(t, report.Daily)
|
||||
}
|
||||
|
||||
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
||||
Location: time.UTC,
|
||||
})
|
||||
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
|
||||
if id == report.Daily {
|
||||
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
@@ -281,7 +350,7 @@ func validPromptInspection(definition report.Definition) promptexec.PromptInspec
|
||||
return promptexec.PromptInspection{
|
||||
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
|
||||
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
|
||||
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,14 +16,12 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -41,12 +41,62 @@ func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing
|
||||
|
||||
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", "", "local-weather")
|
||||
inspect(t, override, report.Hourly, "", "weather-light", "openrouter", "local-weather")
|
||||
}
|
||||
|
||||
func TestPromptInspectionAcceptsMaintainedEndpointOnlyProfile(t *testing.T) {
|
||||
adapter, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: filepath.Join("..", "..", "examples", "weather-light-local-profile.yml")})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
|
||||
Resolved: resolvedPromptProfile(t, report.Hourly), Executor: adapter,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution() error = %v", err)
|
||||
}
|
||||
if result.ProfileID != "weather-light" || result.BackendID != "" || result.ModelName != "weather-local" {
|
||||
t.Fatalf("inspection = %#v", result)
|
||||
}
|
||||
if strings.Contains(fmt.Sprintf("%#v", result), "127.0.0.1") {
|
||||
t.Fatalf("inspection leaks endpoint: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInspectionSupportsRakestrawhomeProfileOffline(t *testing.T) {
|
||||
adapter, err := promptkitadapter.New(promptkitadapter.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
prompt, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
|
||||
Resolved: resolvedPromptProfile(t, report.Hourly),
|
||||
Executor: adapter,
|
||||
Promptkit: config.PromptkitConfig{Profile: "rakestrawhome-gemma-4-31b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution() error = %v", err)
|
||||
}
|
||||
if prompt.ProfileID != "rakestrawhome-gemma-4-31b" || prompt.BackendID != "rakestrawhome" || prompt.ModelName == "" {
|
||||
t.Fatalf("prompt inspection = %#v", prompt)
|
||||
}
|
||||
|
||||
comparison, err := app.InspectComparisonExecution(context.Background(), app.ComparisonInspectionRequest{
|
||||
Resolved: resolvedPromptProfile(t, report.Hourly),
|
||||
ProfileIDs: []string{"rakestrawhome-gemma-4-31b", "weather-deep"},
|
||||
Executor: adapter,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectComparisonExecution() error = %v", err)
|
||||
}
|
||||
if len(comparison.Profiles) != 2 || comparison.Profiles[0].ProfileID != "rakestrawhome-gemma-4-31b" || comparison.Profiles[0].BackendID != "rakestrawhome" || comparison.Profiles[0].ModelName == "" {
|
||||
t.Fatalf("comparison inspection = %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {
|
||||
|
||||
@@ -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": {
|
||||
"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"
|
||||
},
|
||||
"categorical:MRGL": {
|
||||
"plain_language": "Isolated severe storms possible.",
|
||||
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
|
||||
"official_description": "Includes severe storms of either limited organization and longevity or very low coverage.",
|
||||
"relative_level": "1 of 5"
|
||||
},
|
||||
"categorical:SLGT": {
|
||||
"plain_language": "Scattered severe storms possible.",
|
||||
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
|
||||
"official_description": "Implies organized severe thunderstorms are expected, but usually in low coverage with varying levels of intensity.",
|
||||
"relative_level": "2 of 5"
|
||||
},
|
||||
"categorical:ENH": {
|
||||
"plain_language": "Numerous severe storms possible.",
|
||||
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
|
||||
"official_description": "Depicts a greater concentration of organized severe thunderstorms with varying levels of intensity.",
|
||||
"relative_level": "3 of 5"
|
||||
},
|
||||
"categorical:MDT": {
|
||||
"plain_language": "Widespread severe storms likely.",
|
||||
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
|
||||
"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"
|
||||
},
|
||||
"categorical:HIGH": {
|
||||
"plain_language": "Major severe outbreak expected.",
|
||||
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
|
||||
"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"
|
||||
},
|
||||
"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.",
|
||||
"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-29T10:00:00-05:00")},
|
||||
}
|
||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", 20)
|
||||
if len(value) != 3 {
|
||||
t.Fatalf("periods length = %d, want 3", len(value))
|
||||
}
|
||||
@@ -462,19 +462,58 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||
})
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
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 {
|
||||
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 output != nil {
|
||||
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||
}
|
||||
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
|
||||
hourlyWindMph := 14.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
ctx := ModuleContext{
|
||||
Resolved: resolved,
|
||||
Collected: facts.CollectedFacts{
|
||||
Current: &weatherdata.Current{
|
||||
@@ -689,6 +728,14 @@ func testModuleContext() ModuleContext {
|
||||
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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ type DerivedDailySummaryModule struct {
|
||||
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
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"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
||||
} else {
|
||||
value.LowTempF = roundedInt(temperature.Min)
|
||||
}
|
||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
|
||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||
if narrativePrecipitation != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
@@ -162,7 +164,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(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 {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
@@ -301,18 +303,8 @@ func celsiusToFahrenheit(value float64) float64 {
|
||||
}
|
||||
|
||||
func temperatureBandIndex(value int) int {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
band := 1
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
band = 0
|
||||
case remainder >= 7:
|
||||
band = 2
|
||||
}
|
||||
decade, remainder := temperatureBandParts(value)
|
||||
band := temperatureBandQualifierIndex(remainder)
|
||||
return decade*3 + band
|
||||
}
|
||||
|
||||
@@ -348,29 +340,47 @@ func temperaturePhraseF(value forecast.Range) string {
|
||||
}
|
||||
|
||||
func temperatureBandPhrase(value int) string {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
qualifier := "mid"
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
qualifier = "low"
|
||||
case remainder >= 7:
|
||||
qualifier = "upper"
|
||||
if value < 0 {
|
||||
decade, remainder := temperatureBandParts(-value)
|
||||
qualifier := temperatureBandQualifier(remainder)
|
||||
if decade == 0 {
|
||||
return fmt.Sprintf("%s single digits below zero", qualifier)
|
||||
}
|
||||
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
|
||||
}
|
||||
decade, remainder := temperatureBandParts(value)
|
||||
qualifier := temperatureBandQualifier(remainder)
|
||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||
}
|
||||
|
||||
func sentenceCase(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
func temperatureBandParts(value int) (int, int) {
|
||||
decade := value / 10
|
||||
if value < 0 && value%10 != 0 {
|
||||
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 {
|
||||
@@ -382,7 +392,7 @@ func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
}
|
||||
|
||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
key := normalizedKey(daypart.Name)
|
||||
key := forecast.CanonicalDaypartKey(daypart.Name)
|
||||
if key == "" {
|
||||
key = "unnamed"
|
||||
}
|
||||
@@ -391,21 +401,3 @@ func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
}
|
||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
||||
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
|
||||
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily summary: %v", err)
|
||||
}
|
||||
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) {
|
||||
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"} {
|
||||
if strings.Contains(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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
@@ -96,7 +147,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
@@ -215,7 +266,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
value := precipTimingValue(forecast.PrecipTiming{
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
ProbabilityThreshold: 40,
|
||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||
{
|
||||
Start: now,
|
||||
@@ -223,7 +274,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
||||
Value: tt.maxPop,
|
||||
Time: now,
|
||||
},
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
ProbabilityThreshold: 40,
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
@@ -390,6 +487,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||
wantTrend: "steady",
|
||||
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 {
|
||||
@@ -460,6 +571,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||
wantTrend: "steady",
|
||||
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 {
|
||||
@@ -506,6 +631,11 @@ func TestTemperaturePhraseF(t *testing.T) {
|
||||
value: forecast.Range{Max: floatPtr(84)},
|
||||
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",
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
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"
|
||||
)
|
||||
|
||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||
const hourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||
|
||||
type HourlyForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
@@ -184,7 +184,7 @@ func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecas
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -31,18 +31,17 @@ type SourceWarningSummary struct {
|
||||
}
|
||||
|
||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
value := MetadataModule{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||
RunID: ctx.Identity.RunID,
|
||||
ReportID: ctx.Identity.ReportID,
|
||||
Variant: ctx.Identity.Variant,
|
||||
PromptID: ctx.Identity.PromptID,
|
||||
GeneratedAt: ctx.Identity.GeneratedAt,
|
||||
Units: ctx.Identity.Units,
|
||||
Timezone: ctx.Identity.Timezone,
|
||||
ValidPeriod: ctx.Identity.ValidPeriod,
|
||||
Location: copyLocation(ctx.Identity.Location),
|
||||
SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings),
|
||||
}
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type ModuleContext struct {
|
||||
Identity PreparedIdentity
|
||||
Resolved report.Resolved
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
@@ -27,8 +28,8 @@ type ModuleDefinition struct {
|
||||
ID module.ID
|
||||
StanzaName string
|
||||
DefaultOptions any
|
||||
RequiredCollected []module.FactRequirement
|
||||
RequiredDerived []module.FactRequirement
|
||||
RequiredCollected []*factRequirement
|
||||
RequiredDerived []*factRequirement
|
||||
SupportedReports []report.ID
|
||||
MissingData module.MissingDataBehavior
|
||||
AllowDuplicate bool
|
||||
@@ -75,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||
if definition.MissingData == module.MissingDataWarn {
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
options := item.Options
|
||||
if options == nil {
|
||||
options = definition.DefaultOptions
|
||||
}
|
||||
@@ -152,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
||||
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||
var missing []string
|
||||
for _, requirement := range definition.RequiredCollected {
|
||||
if !collectedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
if !requirement.available(ctx) {
|
||||
missing = append(missing, requirement.String())
|
||||
}
|
||||
}
|
||||
for _, requirement := range definition.RequiredDerived {
|
||||
if !derivedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
if !requirement.available(ctx) {
|
||||
missing = append(missing, requirement.String())
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.CollectedCurrentConditions:
|
||||
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
|
||||
func validateFactRequirements(definition ModuleDefinition) error {
|
||||
if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
|
||||
}
|
||||
|
||||
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.RequiresDerivedHourlyPeriods:
|
||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||
case module.RequiresDerivedNarrativePeriods:
|
||||
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||
case module.RequiresDerivedAlertOverlaps:
|
||||
return true
|
||||
case module.RequiresDerivedDailySummaries:
|
||||
return len(ctx.Derived.DailySummaries) > 0
|
||||
case module.RequiresDerivedDaypartSummaries:
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
|
||||
for _, requirement := range requirements {
|
||||
if requirement == nil {
|
||||
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
|
||||
}
|
||||
descriptor, ok := lookupFactRequirement(requirement.identity)
|
||||
if !ok || descriptor != requirement {
|
||||
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity)
|
||||
}
|
||||
if descriptor.category != want {
|
||||
return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||
@@ -272,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
DefaultOptions: module.MetadataOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||
RequiredCollected: []*factRequirement{sourceMetadataRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildMetadataModule,
|
||||
@@ -281,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: "current_conditions",
|
||||
DefaultOptions: module.CurrentConditionsOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||
RequiredCollected: []*factRequirement{currentConditionsRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
@@ -291,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.NarrativeForecast,
|
||||
StanzaName: "narrative_forecast",
|
||||
DefaultOptions: module.NarrativeForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||
RequiredCollected: []*factRequirement{narrativeForecastRequirement},
|
||||
RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
@@ -301,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: "hourly_forecast",
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
RequiredCollected: []*factRequirement{hourlyForecastRequirement},
|
||||
RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
@@ -312,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||
RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
@@ -321,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.DerivedDaypartSummaries,
|
||||
StanzaName: "derived_daypart_summaries",
|
||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
@@ -331,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||
RequiredDerived: []*factRequirement{precipTimingRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
@@ -340,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: "alert_digest",
|
||||
DefaultOptions: module.AlertDigestOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||
RequiredCollected: []*factRequirement{alertsRequirement},
|
||||
RequiredDerived: []*factRequirement{alertOverlapsRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
@@ -350,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
@@ -360,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||
RequiredCollected: []*factRequirement{discussionRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
@@ -369,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildSPCConvectiveDiscussionModule,
|
||||
@@ -379,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
DefaultOptions: module.WeatherStoryOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||
RequiredCollected: []*factRequirement{weatherStoryRequirement},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildWeatherStoryModule,
|
||||
@@ -388,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.OutdoorWindows,
|
||||
StanzaName: "outdoor_windows",
|
||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
@@ -397,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.TodayPlanning,
|
||||
StanzaName: "today_planning",
|
||||
DefaultOptions: module.TodayPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||
SupportedReports: []report.ID{report.Today},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTodayPlanningModule,
|
||||
@@ -406,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||
SupportedReports: []report.ID{report.Tomorrow},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTomorrowPlanningModule,
|
||||
@@ -415,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.DailyPlanning,
|
||||
StanzaName: "daily_planning",
|
||||
DefaultOptions: module.DailyPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||
SupportedReports: []report.ID{report.Daily},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
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) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{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{
|
||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||
{ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition() error = %v", err)
|
||||
@@ -466,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
|
||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||
if spcOutlooksRequirement.available(ctx) {
|
||||
t.Fatal("SPC outlook requirement is available, want false without source")
|
||||
}
|
||||
|
||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||
if !spcOutlooksRequirement.available(ctx) {
|
||||
t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||
if spcDerivedOutlooksRequirement.available(ctx) {
|
||||
t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
|
||||
}
|
||||
|
||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||
if !spcDerivedOutlooksRequirement.available(ctx) {
|
||||
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"
|
||||
)
|
||||
|
||||
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"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
@@ -21,9 +23,7 @@ type Metadata struct {
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
type LocationContext struct {
|
||||
@@ -33,24 +33,6 @@ type LocationContext struct {
|
||||
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 {
|
||||
Resolved report.Resolved
|
||||
Bundle *weatherdata.Bundle
|
||||
@@ -59,10 +41,10 @@ type BuildContext struct {
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
func BuildMetadata(ctx BuildContext) Metadata {
|
||||
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||
return Metadata{
|
||||
return PreparedIdentity{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
@@ -74,9 +56,7 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceLocationID: sourceLocationID,
|
||||
SourceLocation: sourceLocation,
|
||||
Sources: sourceMetadata(ctx.Bundle),
|
||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||
Alerts: alertStatus(ctx.Bundle),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||
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 {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
@@ -162,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||
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 {
|
||||
switch id {
|
||||
case report.Daily, report.Today:
|
||||
|
||||
@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
|
||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||
return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
|
||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||
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 {
|
||||
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||
}
|
||||
var definitions map[string]SPCOutlookBackgroundDefinition
|
||||
if err := json.Unmarshal(data, &definitions); err != nil {
|
||||
var asset spcOutlookBackgroundDefinitionAsset
|
||||
if err := json.Unmarshal(data, &asset); err != nil {
|
||||
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 {
|
||||
|
||||
@@ -65,11 +65,9 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.BackgroundDefinition == nil ||
|
||||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
|
||||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
|
||||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
|
||||
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
|
||||
wantBackground := spcOutlookBackgroundDefinition("categorical", "SLGT")
|
||||
if got.BackgroundDefinition == nil || wantBackground == nil || *got.BackgroundDefinition != *wantBackground {
|
||||
t.Fatalf("background definition = %#v, want matching Slight Risk helper %#v", got.BackgroundDefinition, wantBackground)
|
||||
}
|
||||
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
@@ -193,12 +191,33 @@ func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||
func TestSPCOutlookBackgroundDefinitionAssetRecordsProvenance(t *testing.T) {
|
||||
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
||||
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
||||
var asset spcOutlookBackgroundDefinitionAsset
|
||||
if err := json.Unmarshal(data, &asset); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if asset.Provenance.ReviewedOn == "" || asset.Provenance.ReviewOwner == "" || asset.Provenance.ReviewSchedule == "" {
|
||||
t.Fatalf("asset provenance = %#v, want review metadata", asset.Provenance)
|
||||
}
|
||||
wantSources := map[string]string{
|
||||
"https://www.spc.noaa.gov/about/outlooks/": "categorical outlook descriptions",
|
||||
"https://www.spc.noaa.gov/exper/conditional-intensity-information": "conditional intensity group descriptions",
|
||||
}
|
||||
if len(asset.Provenance.Sources) != len(wantSources) {
|
||||
t.Fatalf("asset provenance sources = %#v, want %d authoritative sources", asset.Provenance.Sources, len(wantSources))
|
||||
}
|
||||
for _, source := range asset.Provenance.Sources {
|
||||
if source.UpdatedOn == "" || wantSources[source.URL] != source.AppliesTo {
|
||||
t.Fatalf("asset provenance source = %#v, want authoritative source metadata", source)
|
||||
}
|
||||
delete(wantSources, source.URL)
|
||||
}
|
||||
if len(wantSources) != 0 {
|
||||
t.Fatalf("asset provenance missing sources = %#v", wantSources)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -41,6 +42,15 @@ type TodayPlanning struct {
|
||||
LateDayChangeWatch []string
|
||||
}
|
||||
|
||||
const outdoorIndicatorRiskScore = 25
|
||||
|
||||
const (
|
||||
morningDaypartIdentity = "morning"
|
||||
afternoonDaypartIdentity = "afternoon"
|
||||
eveningDaypartIdentity = "evening"
|
||||
overnightDaypartIdentity = "overnight"
|
||||
)
|
||||
|
||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
var best *OutdoorWindow
|
||||
var worst *OutdoorWindow
|
||||
@@ -72,7 +82,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
if isOutsideWorkday(daypart) {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
@@ -91,7 +101,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||
}
|
||||
|
||||
for _, name := range []string{"afternoon", "evening"} {
|
||||
for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} {
|
||||
daypart := daypartNamed(summary.Dayparts, name)
|
||||
if daypart != nil {
|
||||
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||
@@ -124,7 +134,7 @@ func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morni
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
if isOutsideWorkday(daypart) {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
@@ -153,10 +163,10 @@ func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||
windows := buildOutdoorWindows(dayparts)
|
||||
var notes []string
|
||||
if windows.Best != nil {
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
}
|
||||
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
@@ -183,7 +193,7 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
|
||||
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
prefix := capitalizeFirst(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Late-day"
|
||||
}
|
||||
@@ -204,7 +214,7 @@ func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
|
||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
prefix := capitalizeFirst(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Daytime"
|
||||
}
|
||||
@@ -247,8 +257,9 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
}
|
||||
|
||||
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
||||
identity := forecast.CanonicalDaypartKey(name)
|
||||
for i := range dayparts {
|
||||
if strings.EqualFold(dayparts[i].Name, name) {
|
||||
if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
|
||||
return &dayparts[i]
|
||||
}
|
||||
}
|
||||
@@ -275,7 +286,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
reasons = append(reasons, "alert overlap")
|
||||
}
|
||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||
score += 25
|
||||
score += outdoorIndicatorRiskScore
|
||||
if daypart.Indicators.Heat {
|
||||
reasons = append(reasons, "heat risk")
|
||||
}
|
||||
@@ -283,6 +294,19 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
reasons = append(reasons, "cold risk")
|
||||
}
|
||||
}
|
||||
for _, hazard := range []struct {
|
||||
present bool
|
||||
reason string
|
||||
}{
|
||||
{present: daypart.Indicators.Snow, reason: "snow risk"},
|
||||
{present: daypart.Indicators.Ice, reason: "ice risk"},
|
||||
{present: daypart.Indicators.Fog, reason: "fog risk"},
|
||||
} {
|
||||
if hazard.present {
|
||||
score += outdoorIndicatorRiskScore
|
||||
reasons = append(reasons, hazard.reason)
|
||||
}
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
reasons = append(reasons, "quiet weather")
|
||||
}
|
||||
@@ -382,9 +406,20 @@ func appendUnique(values []string, candidates ...string) []string {
|
||||
return values
|
||||
}
|
||||
|
||||
func titleWord(value string) string {
|
||||
func isOutsideWorkday(daypart forecast.DaypartSummary) bool {
|
||||
switch forecast.CanonicalDaypartKey(daypart.Name) {
|
||||
case overnightDaypartIdentity, eveningDaypartIdentity:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func capitalizeFirst(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(value[:1]) + value[1:]
|
||||
runes := []rune(value)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type WeatherStoryModule struct {
|
||||
|
||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
story := ctx.Collected.WeatherStory
|
||||
if story == nil {
|
||||
if story == nil || !story.HasUsableContent() {
|
||||
return nil, nil
|
||||
}
|
||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||
|
||||
@@ -69,36 +69,6 @@ func TestResolveComparisonActionBuildsExplicitRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveComparisonActionMatchesReportDatePolicies(t *testing.T) {
|
||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n timezone: America/Chicago\n")
|
||||
runner := comparisonRunner(t, t.TempDir())
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDay string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "daily requires date", args: []string{"daily", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
|
||||
{name: "today uses current local date", args: []string{"today", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-29"},
|
||||
{name: "today accepts date", args: []string{"today", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-30"},
|
||||
{name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
|
||||
{name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req, _, err := runner.resolveComparisonAction(test.args)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("resolveComparisonAction() error = nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || req.Date.Format("2006-01-02") != test.wantDay {
|
||||
t.Fatalf("request/error = %#v/%v", req, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n")
|
||||
@@ -215,6 +185,40 @@ func TestCompareCommandWritesStructuredPartialFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareCommandPreservesMixedCancellationOutcomes(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
|
||||
profileFailure := comparison.NewSafeError("validation_rejected", "validate prompt execution failed")
|
||||
canceledProfile := comparison.NewSafeError("canceled", "profile execution canceled")
|
||||
result := comparisonResult("/reports/comparison-daily", []app.ComparisonProfileResult{
|
||||
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationFailed, Error: &profileFailure},
|
||||
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationSkipped, Error: &canceledProfile},
|
||||
})
|
||||
runner := comparisonRunner(t, workingDir)
|
||||
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
|
||||
return result, context.Canceled
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}, &stdout, &stderr)
|
||||
if !errors.Is(err, context.Canceled) || stderr.Len() != 0 {
|
||||
t.Fatalf("Run() error/stderr = %v/%q", err, stderr.String())
|
||||
}
|
||||
var summary comparisonSummary
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "canceled" || len(summary.Results) != 2 {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
if first := summary.Results[0]; first.Error == nil || first.Error.Category != "validation_rejected" || first.ValidationStatus != string(promptexec.ValidationFailed) {
|
||||
t.Fatalf("completed profile summary = %#v", first)
|
||||
}
|
||||
if second := summary.Results[1]; second.Error == nil || second.Error.Category != "canceled" || second.ValidationStatus != string(promptexec.ValidationSkipped) {
|
||||
t.Fatalf("canceled profile summary = %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
|
||||
@@ -223,9 +227,9 @@ func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
|
||||
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(outputDirectory, "01-weather-light.md")},
|
||||
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(outputDirectory, "02-weather-deep.md")},
|
||||
})
|
||||
backupPath := filepath.Join(workingDir, ".comparison-daily.backup-retained")
|
||||
recoveryPath := filepath.Join(workingDir, ".comparison-daily.backup-retained")
|
||||
cleanupCause := errors.New("filesystem cleanup detail")
|
||||
cleanupErr := &comparison.PublicationCleanupError{RetainedBackupPath: backupPath, Err: cleanupCause}
|
||||
cleanupErr := &comparison.PublicationCleanupError{RecoveryState: comparison.BackupRecoveryComplete, RecoveryPath: recoveryPath, Err: cleanupCause}
|
||||
runner := comparisonRunner(t, workingDir)
|
||||
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
|
||||
return result, cleanupErr
|
||||
@@ -240,10 +244,10 @@ func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "publication_cleanup" || summary.Error.Message != "comparison published but cleanup did not complete" || summary.ManifestPath != result.ManifestPath || summary.DataPackagePath != result.DataPackagePath || summary.Results[0].ReportPath == "" {
|
||||
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "publication_cleanup" || summary.Error.Message != "comparison published but a complete prior bundle remains" || summary.ManifestPath != result.ManifestPath || summary.DataPackagePath != result.DataPackagePath || summary.Results[0].ReportPath == "" {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
for _, unsafe := range []string{cleanupCause.Error(), backupPath} {
|
||||
for _, unsafe := range []string{cleanupCause.Error(), recoveryPath} {
|
||||
if strings.Contains(stdout.String(), unsafe) {
|
||||
t.Fatalf("summary contains unsafe recovery detail %q: %s", unsafe, stdout.String())
|
||||
}
|
||||
@@ -318,7 +322,7 @@ func TestCompareCommandLeavesPreExecutionFailuresUnstructured(t *testing.T) {
|
||||
|
||||
func TestCompareHelpIncludesCommand(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") {
|
||||
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") || !strings.Contains(stdout.String(), "--date YYYY-MM-DD") || !strings.Contains(stdout.String(), "Suppress action summaries and routine batch status output.") {
|
||||
t.Fatalf("help/error = %q/%v", stdout.String(), err)
|
||||
}
|
||||
}
|
||||
@@ -351,7 +355,7 @@ func comparisonResult(outputDirectory string, results []app.ComparisonProfileRes
|
||||
started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ComparisonResult{
|
||||
ComparisonID: "comparison_run-123", ReportID: "daily", ReportName: "Daily Report",
|
||||
PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
|
||||
PromptID: "weather.daily_generated_text", PromptVersion: "2.1.0", PromptHash: strings.Repeat("a", 64),
|
||||
StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: outputDirectory,
|
||||
ManifestPath: filepath.Join(outputDirectory, "comparison.json"), DataPackagePath: filepath.Join(outputDirectory, "data-package.yml"),
|
||||
|
||||
141
internal/cli/generate_test.go
Normal file
141
internal/cli/generate_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestGenerateInputFailuresDoNotConstructOrExecute(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "MissingReport", args: nil, wantErr: "generate requires a report name"},
|
||||
{name: "UnknownReport", args: []string{"unknown"}, wantErr: "unknown generate report"},
|
||||
{name: "MissingDailyDate", args: []string{"daily", "--config", filepath.Join(t.TempDir(), "missing.yml")}, wantErr: "generate daily requires --date YYYY-MM-DD"},
|
||||
{name: "MalformedDailyDate", args: []string{"daily", "--date", "not-a-date", "--config", configPath}},
|
||||
{name: "MalformedTodayDate", args: []string{"today", "--date", "not-a-date", "--config", configPath}},
|
||||
{name: "UnexpectedArgument", args: []string{"today", "extra"}},
|
||||
{name: "UnsupportedFlag", args: []string{"today", "--out-dir", "reports"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return &factoryExecutor{}, nil
|
||||
},
|
||||
generateDetailed: func(context.Context, app.GenerateRequest) (*app.ReportResult, error) {
|
||||
applicationCalls++
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), append([]string{"generate"}, tt.args...), &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil")
|
||||
}
|
||||
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Run() error = %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
if factoryCalls != 0 || applicationCalls != 0 || stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("factory/application calls/output = %d/%d/%q/%q", factoryCalls, applicationCalls, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCommandProjectsActionResults(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
failure := errors.New("prompt execution failed")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
quiet bool
|
||||
actionErr error
|
||||
}{
|
||||
{name: "Success"},
|
||||
{name: "Failure", actionErr: failure},
|
||||
{name: "QuietFailure", quiet: true, actionErr: failure},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
executor := &factoryExecutor{}
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return executor, nil
|
||||
},
|
||||
generateDetailed: func(_ context.Context, req app.GenerateRequest) (*app.ReportResult, error) {
|
||||
applicationCalls++
|
||||
if req.Executor != executor {
|
||||
t.Fatal("generate request did not receive the constructed executor")
|
||||
}
|
||||
return generatedReportResult(), tt.actionErr
|
||||
},
|
||||
}
|
||||
args := []string{"generate", "daily", "--date", "2026-05-29", "--config", configPath}
|
||||
if tt.quiet {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), args, &stdout, &stderr)
|
||||
if !errors.Is(err, tt.actionErr) || factoryCalls != 1 || applicationCalls != 1 || stderr.Len() != 0 {
|
||||
t.Fatalf("error/calls/stderr = %v/%d/%d/%q", err, factoryCalls, applicationCalls, stderr.String())
|
||||
}
|
||||
if tt.quiet {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("quiet stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var summary generateSummary
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
wantStatus, wantError := summaryStatusSucceeded, ""
|
||||
if tt.actionErr != nil {
|
||||
wantStatus, wantError = summaryStatusFailed, tt.actionErr.Error()
|
||||
}
|
||||
if summary.Command != commandGenerate || summary.Status != wantStatus || summary.Error != wantError || summary.OutputPath != "/reports/daily.md" {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func actionConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func generatedReportResult() *app.ReportResult {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
return &app.ReportResult{
|
||||
ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.1.0",
|
||||
RunID: "daily-20260529", GeneratedAt: generatedAt, Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)},
|
||||
ProfileID: "weather-light", BackendID: "local", ModelName: "weather-model", ValidationStatus: promptexec.ValidationPassed,
|
||||
OutputPath: "/reports/daily.md",
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
||||
continue
|
||||
}
|
||||
if item.Status == "canceled" {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=canceled\n", item.ReportID)
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
||||
}
|
||||
if result.Notification != nil {
|
||||
@@ -58,5 +62,5 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||
}
|
||||
_, _ = fmt.Fprintln(stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d canceled=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed, result.Canceled)
|
||||
}
|
||||
|
||||
75
internal/cli/report_date.go
Normal file
75
internal/cli/report_date.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
var (
|
||||
errReportDateRequired = errors.New("report date is required")
|
||||
errReportDateNotAccepted = errors.New("report does not accept a date")
|
||||
)
|
||||
|
||||
type reportDatePolicy struct {
|
||||
acceptsDate bool
|
||||
requiresDate bool
|
||||
defaultsToLocalDate bool
|
||||
}
|
||||
|
||||
func reportDatePolicyFor(report app.ReportKind) reportDatePolicy {
|
||||
switch report {
|
||||
case app.ReportDaily:
|
||||
return reportDatePolicy{acceptsDate: true, requiresDate: true}
|
||||
case app.ReportToday:
|
||||
return reportDatePolicy{acceptsDate: true, defaultsToLocalDate: true}
|
||||
default:
|
||||
return reportDatePolicy{}
|
||||
}
|
||||
}
|
||||
|
||||
func addReportDateFlag(fs interface {
|
||||
StringVar(*string, string, string, string)
|
||||
}, report app.ReportKind, value *string) {
|
||||
if reportDatePolicyFor(report).acceptsDate {
|
||||
fs.StringVar(value, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
}
|
||||
|
||||
func reportDateRequiredError(action string, report app.ReportKind, value string) error {
|
||||
if reportDatePolicyFor(report).requiresDate && value == "" {
|
||||
return fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveActionReportDate(action string, report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
|
||||
date, err := resolveReportDate(report, value, location, now)
|
||||
if errors.Is(err, errReportDateRequired) {
|
||||
return time.Time{}, fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
|
||||
}
|
||||
return date, err
|
||||
}
|
||||
|
||||
func resolveReportDate(report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
|
||||
policy := reportDatePolicyFor(report)
|
||||
if !policy.acceptsDate {
|
||||
if value != "" {
|
||||
return time.Time{}, errReportDateNotAccepted
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
if value == "" {
|
||||
if policy.requiresDate {
|
||||
return time.Time{}, errReportDateRequired
|
||||
}
|
||||
if policy.defaultsToLocalDate {
|
||||
return timeutil.LocalDate(now, location), nil
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return timeutil.ParseLocalDate(value, location)
|
||||
}
|
||||
101
internal/cli/report_date_test.go
Normal file
101
internal/cli/report_date_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
)
|
||||
|
||||
func TestReportDatePolicy(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
report app.ReportKind
|
||||
value string
|
||||
wantDay string
|
||||
wantErr error
|
||||
malformed bool
|
||||
}{
|
||||
{name: "DailyRequiresDate", report: app.ReportDaily, wantErr: errReportDateRequired},
|
||||
{name: "DailyParsesDate", report: app.ReportDaily, value: "2026-05-30", wantDay: "2026-05-30"},
|
||||
{name: "TodayDefaultsInConfiguredTimezone", report: app.ReportToday, wantDay: "2026-05-29"},
|
||||
{name: "TodayParsesDate", report: app.ReportToday, value: "2026-05-30", wantDay: "2026-05-30"},
|
||||
{name: "TodayRejectsMalformedDate", report: app.ReportToday, value: "not-a-date", malformed: true},
|
||||
{name: "TomorrowHasNoDate", report: app.ReportTomorrow},
|
||||
{name: "TomorrowRejectsDate", report: app.ReportTomorrow, value: "2026-05-30", wantErr: errReportDateNotAccepted},
|
||||
{name: "HourlyHasNoDate", report: app.ReportHourly},
|
||||
{name: "HourlyRejectsDate", report: app.ReportHourly, value: "2026-05-30", wantErr: errReportDateNotAccepted},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
date, err := resolveReportDate(tt.report, tt.value, location, now)
|
||||
if tt.malformed {
|
||||
if err == nil {
|
||||
t.Fatal("resolveReportDate() error = nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("resolveReportDate() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReportDate() error = %v", err)
|
||||
}
|
||||
if tt.wantDay == "" {
|
||||
if !date.IsZero() {
|
||||
t.Fatalf("resolveReportDate() = %v, want zero date", date)
|
||||
}
|
||||
return
|
||||
}
|
||||
if date.Location() != location || date.Format("2006-01-02") != tt.wantDay {
|
||||
t.Fatalf("resolveReportDate() = %v, want %s in %s", date, tt.wantDay, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportDateFlagsFollowPolicy(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
report app.ReportKind
|
||||
acceptsDate bool
|
||||
}{
|
||||
{report: app.ReportDaily, acceptsDate: true},
|
||||
{report: app.ReportToday, acceptsDate: true},
|
||||
{report: app.ReportTomorrow},
|
||||
{report: app.ReportHourly},
|
||||
} {
|
||||
t.Run(string(tt.report), func(t *testing.T) {
|
||||
_, generateErr := parseGenerateFlags(tt.report, []string{"--date", "2026-05-30"})
|
||||
_, comparisonErr := parseComparisonFlags(tt.report, []string{"--date", "2026-05-30"})
|
||||
if tt.acceptsDate && (generateErr != nil || comparisonErr != nil) {
|
||||
t.Fatalf("date flag errors = %v/%v, want accepted", generateErr, comparisonErr)
|
||||
}
|
||||
if !tt.acceptsDate && (generateErr == nil || comparisonErr == nil) {
|
||||
t.Fatalf("date flag errors = %v/%v, want rejected", generateErr, comparisonErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveActionReportDateKeepsActionSpecificMissingDateErrors(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||
for _, action := range []string{"generate", "compare"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
_, err := resolveActionReportDate(action, app.ReportDaily, "", location, now)
|
||||
want := action + " daily requires --date YYYY-MM-DD"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type generateSummary struct {
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ValidationStatus string `json:"validationStatus,omitempty"`
|
||||
RepairAttempts *int `json:"repairAttempts,omitempty"`
|
||||
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -70,6 +71,7 @@ type batchSummary struct {
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Canceled int `json:"canceled,omitempty"`
|
||||
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||
Reports []app.BatchReportResult `json:"reports"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -105,6 +107,7 @@ type comparisonProfileSummary struct {
|
||||
ModelName string `json:"modelName"`
|
||||
Status string `json:"status"`
|
||||
ValidationStatus string `json:"validationStatus,omitempty"`
|
||||
RepairAttempts *int `json:"repairAttempts,omitempty"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||
Error *comparison.SafeError `json:"error,omitempty"`
|
||||
@@ -128,6 +131,10 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||
summary.ProfileID, summary.BackendID, summary.ModelName = result.ProfileID, result.BackendID, result.ModelName
|
||||
summary.SourceWarnings = append([]weatherdata.SourceWarning(nil), result.SourceWarnings...)
|
||||
summary.ValidationStatus = string(result.ValidationStatus)
|
||||
if result.RepairAttempts != nil {
|
||||
value := *result.RepairAttempts
|
||||
summary.RepairAttempts = &value
|
||||
}
|
||||
summary.OutputPath = result.OutputPath
|
||||
summary.LLMDebugPath = result.LLMDebugPath
|
||||
summary.Notification = newGenerateNotificationSummary(result.Notification)
|
||||
@@ -161,7 +168,12 @@ func newGenerateNotificationSummary(result *app.NotificationResult) *generateNot
|
||||
return summary
|
||||
}
|
||||
|
||||
func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||
func repairAttemptsCopy(value int) *int {
|
||||
copy := value
|
||||
return ©
|
||||
}
|
||||
|
||||
func newBatchSummary(result *app.BatchResult, err error) batchSummary {
|
||||
summary := batchSummary{Command: commandRun}
|
||||
if result == nil {
|
||||
return summary
|
||||
@@ -174,10 +186,11 @@ func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||
summary.Total = result.Total
|
||||
summary.Succeeded = result.Succeeded
|
||||
summary.Failed = result.Failed
|
||||
summary.Canceled = result.Canceled
|
||||
summary.Notification = result.Notification
|
||||
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||
if summary.Status == summaryStatusFailed {
|
||||
summary.Error = app.BatchError{Result: result}.Error()
|
||||
summary.Error = app.BatchError{Result: result, Cause: err}.Error()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -201,6 +214,9 @@ func newComparisonSummary(result *app.ComparisonResult, err error) comparisonSum
|
||||
Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath,
|
||||
LLMDebugPath: profile.LLMDebugPath, Error: profile.Error,
|
||||
})
|
||||
if profile.RepairAttempts != nil {
|
||||
summary.Results[len(summary.Results)-1].RepairAttempts = repairAttemptsCopy(*profile.RepairAttempts)
|
||||
}
|
||||
}
|
||||
summary.Status = comparisonSummaryStatus(result, err)
|
||||
if err != nil {
|
||||
@@ -223,7 +239,7 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
|
||||
}
|
||||
var cleanupErr *comparison.PublicationCleanupError
|
||||
if errors.As(err, &cleanupErr) {
|
||||
safe := comparison.NewSafeError("publication_cleanup", "comparison published but cleanup did not complete")
|
||||
safe := comparison.NewSafeError("publication_cleanup", publicationCleanupMessage(cleanupErr.RecoveryState))
|
||||
return &safe
|
||||
}
|
||||
var destinationErr *comparison.DestinationError
|
||||
@@ -247,6 +263,19 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
|
||||
return &safe
|
||||
}
|
||||
|
||||
func publicationCleanupMessage(state comparison.BackupRecoveryState) string {
|
||||
switch state {
|
||||
case comparison.BackupRecoveryComplete:
|
||||
return "comparison published but a complete prior bundle remains"
|
||||
case comparison.BackupRecoveryPartial:
|
||||
return "comparison published but partial cleanup remnants remain"
|
||||
case comparison.BackupRecoveryAbsent:
|
||||
return "comparison published but no prior bundle remains"
|
||||
default:
|
||||
return "comparison published but cleanup recovery state is unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonAggregateErrorMessage(err error) (string, bool) {
|
||||
const prefix = "comparison completed with "
|
||||
const suffix = " failed profiles"
|
||||
@@ -267,7 +296,7 @@ func batchSummaryStatus(result *app.BatchResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||
if result.Failed > 0 || result.Canceled > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||
return summaryStatusFailed
|
||||
}
|
||||
return summaryStatusSucceeded
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func TestGenerateSummaryUsesActiveResultFields(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
summary := newGenerateSummary(&app.ReportResult{ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", RunID: "run-123", GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)}, ProfileID: "weather-balanced", BackendID: "openrouter", ModelName: "model", SourceWarnings: []weatherdata.SourceWarning{{Source: "alerts", Message: "source unavailable"}}, ValidationStatus: promptexec.ValidationPassed, OutputPath: "/reports/daily.md"}, nil)
|
||||
summary := newGenerateSummary(&app.ReportResult{ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.1.0", RunID: "run-123", GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)}, ProfileID: "weather-balanced", BackendID: "openrouter", ModelName: "model", SourceWarnings: []weatherdata.SourceWarning{{Source: "alerts", Message: "source unavailable"}}, ValidationStatus: promptexec.ValidationPassed, OutputPath: "/reports/daily.md"}, nil)
|
||||
if summary.OutputPath == "" || summary.ProfileID == "" || summary.ValidationStatus != string(promptexec.ValidationPassed) || len(summary.SourceWarnings) != 1 {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestComparisonSummaryUsesLockedOrderAndSafeFields(t *testing.T) {
|
||||
profileFailure := comparison.NewSafeError("generation", "execute prompt failed")
|
||||
result := &app.ComparisonResult{
|
||||
ComparisonID: "comparison_run-123", ReportID: report.Daily, ReportName: "Daily Report",
|
||||
PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
|
||||
PromptID: "weather.daily_generated_text", PromptVersion: "2.1.0", PromptHash: strings.Repeat("a", 64),
|
||||
StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: "/reports/comparison-daily",
|
||||
ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml",
|
||||
@@ -113,7 +113,7 @@ func TestComparisonSummaryClassifiesCompleteAndAllFailedResults(t *testing.T) {
|
||||
started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
failure := comparison.NewSafeError("generation", "execute prompt failed")
|
||||
complete := &app.ComparisonResult{
|
||||
ComparisonID: "comparison_run-123", ReportID: report.Daily, PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
|
||||
ComparisonID: "comparison_run-123", ReportID: report.Daily, PromptID: "weather.daily_generated_text", PromptVersion: "2.1.0", PromptHash: strings.Repeat("a", 64),
|
||||
StartedAt: started, FinishedAt: started, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: started, End: started.Add(time.Hour)},
|
||||
OutputDirectory: "/reports/comparison-daily", ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml",
|
||||
Total: 2, Succeeded: 2,
|
||||
@@ -179,12 +179,36 @@ func TestSafeComparisonSummaryErrorClassifiesWrappedFailures(t *testing.T) {
|
||||
message: "comparison destination preflight failed",
|
||||
},
|
||||
{
|
||||
name: "publication cleanup",
|
||||
name: "complete cleanup recovery",
|
||||
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
|
||||
RetainedBackupPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
|
||||
RecoveryState: comparison.BackupRecoveryComplete, RecoveryPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
|
||||
}),
|
||||
category: "publication_cleanup",
|
||||
message: "comparison published but cleanup did not complete",
|
||||
message: "comparison published but a complete prior bundle remains",
|
||||
},
|
||||
{
|
||||
name: "partial cleanup remnants",
|
||||
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
|
||||
RecoveryState: comparison.BackupRecoveryPartial, RecoveryPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
|
||||
}),
|
||||
category: "publication_cleanup",
|
||||
message: "comparison published but partial cleanup remnants remain",
|
||||
},
|
||||
{
|
||||
name: "absent cleanup recovery",
|
||||
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
|
||||
RecoveryState: comparison.BackupRecoveryAbsent, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
|
||||
}),
|
||||
category: "publication_cleanup",
|
||||
message: "comparison published but no prior bundle remains",
|
||||
},
|
||||
{
|
||||
name: "unknown cleanup recovery",
|
||||
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
|
||||
RecoveryState: comparison.BackupRecoveryUnknown, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
|
||||
}),
|
||||
category: "publication_cleanup",
|
||||
message: "comparison published but cleanup recovery state is unknown",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
|
||||
@@ -36,11 +36,12 @@ Options:
|
||||
--units VALUE Override weather API units.
|
||||
--tz NAME Override weather API timezone.
|
||||
--out PATH Write the generated Markdown report to PATH.
|
||||
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
|
||||
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH (Unix only).
|
||||
--profile PROFILE Select a prompt profile for compare; repeat for every profile.
|
||||
--date YYYY-MM-DD Required for generate/compare daily; optional for generate/compare today.
|
||||
--out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory.
|
||||
--replace Authorize replacement of a recognized comparison bundle.
|
||||
--quiet Suppress successful action output.
|
||||
--quiet Suppress action summaries and routine batch status output.
|
||||
`
|
||||
|
||||
type Runner struct {
|
||||
@@ -48,6 +49,7 @@ type Runner struct {
|
||||
ExecutorFactory ExecutorFactory
|
||||
Version string
|
||||
WorkingDir string
|
||||
generateDetailed func(context.Context, app.GenerateRequest) (*app.ReportResult, error)
|
||||
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
|
||||
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error)
|
||||
}
|
||||
@@ -86,7 +88,11 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := app.GenerateDetailed(ctx, req)
|
||||
generateDetailed := r.generateDetailed
|
||||
if generateDetailed == nil {
|
||||
generateDetailed = app.GenerateDetailed
|
||||
}
|
||||
result, err := generateDetailed(ctx, req)
|
||||
if result != nil {
|
||||
summary := newGenerateSummary(result, err)
|
||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
|
||||
@@ -105,12 +111,15 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
}
|
||||
result, err := runBatchDetailed(ctx, req)
|
||||
if result != nil {
|
||||
summary := newBatchSummary(result)
|
||||
summary := newBatchSummary(result, err)
|
||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||
writeBatchStatus(w, result)
|
||||
}); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Status == summaryStatusFailed {
|
||||
return app.BatchError{Result: result}
|
||||
}
|
||||
@@ -192,6 +201,9 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
if err := reportDateRequiredError("generate", reportKind, opts.Date); err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
Units: opts.Units,
|
||||
@@ -200,11 +212,19 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
now := r.Clock.Now()
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: reportKind,
|
||||
LLMDebugDir: opts.LLMDebugDir,
|
||||
Now: now,
|
||||
}
|
||||
|
||||
req.Date, err = resolveActionReportDate("generate", reportKind, opts.Date, location, now)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
@@ -217,36 +237,12 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: reportKind,
|
||||
WorkingDir: workingDir,
|
||||
OutputPath: outputPath,
|
||||
LLMDebugDir: opts.LLMDebugDir,
|
||||
Now: r.Clock.Now(),
|
||||
Executor: executor,
|
||||
}
|
||||
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
if opts.Date == "" {
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||
}
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
case app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
}
|
||||
req.WorkingDir, req.OutputPath = workingDir, outputPath
|
||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
req.Executor = executor
|
||||
|
||||
return req, opts.commonOptions, nil
|
||||
}
|
||||
@@ -299,19 +295,7 @@ func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, c
|
||||
Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...),
|
||||
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
|
||||
}
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
if opts.Date == "" {
|
||||
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare daily requires --date YYYY-MM-DD")
|
||||
}
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
case app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
}
|
||||
}
|
||||
req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now())
|
||||
if err != nil {
|
||||
return app.ComparisonRequest{}, commonOptions{}, err
|
||||
}
|
||||
@@ -373,10 +357,8 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
addCommonFlags(fs, &opts.commonOptions, true)
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
addReportDateFlag(fs, report, &opts.Date)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return generateOptions{}, err
|
||||
}
|
||||
@@ -392,7 +374,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
||||
opts := commonOptions{}
|
||||
addCommonFlags(fs, &opts, false)
|
||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return commonOptions{}, err
|
||||
}
|
||||
@@ -409,11 +391,9 @@ func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptio
|
||||
addCommonFlags(fs, &opts.commonOptions, false)
|
||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory")
|
||||
fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
addReportDateFlag(fs, report, &opts.Date)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return comparisonOptions{}, err
|
||||
}
|
||||
@@ -427,7 +407,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
||||
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
|
||||
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH (Unix only)")
|
||||
if includeOutput {
|
||||
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
|
||||
}
|
||||
|
||||
@@ -193,6 +193,103 @@ func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunActionPreservesBatchCancellation(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchMorning, Total: 2, Succeeded: 1, Canceled: 1,
|
||||
Reports: []app.BatchReportResult{
|
||||
{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"},
|
||||
{ReportID: "tomorrow", Status: "canceled"},
|
||||
},
|
||||
Notification: &app.BatchNotificationResult{Status: "skipped", Reason: "batch canceled"},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
return &factoryExecutor{}, nil
|
||||
},
|
||||
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
|
||||
return result, cause
|
||||
},
|
||||
}
|
||||
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var summary batchSummary
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 0 || summary.Canceled != 1 || summary.Error == "" || len(summary.Reports) != 2 || summary.Reports[1].Status != "canceled" || !strings.Contains(stderr.String(), "report=tomorrow status=canceled") || !strings.Contains(stderr.String(), "canceled=1") {
|
||||
t.Fatalf("summary/stderr = %#v/%q", summary, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandProjectsSuccessAndReportFailure(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
result *app.BatchResult
|
||||
wantStatus string
|
||||
wantError bool
|
||||
statusLine string
|
||||
}{
|
||||
{
|
||||
name: "Success",
|
||||
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Succeeded: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"}}},
|
||||
wantStatus: summaryStatusSucceeded,
|
||||
statusLine: `report=today status=succeeded output="/reports/today.md"`,
|
||||
},
|
||||
{
|
||||
name: "ReportFailure",
|
||||
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Failed: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "failed", Error: "prompt execution failed"}}},
|
||||
wantStatus: summaryStatusFailed,
|
||||
wantError: true,
|
||||
statusLine: `report=today status=failed error="prompt execution failed"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return &factoryExecutor{}, nil
|
||||
},
|
||||
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
|
||||
applicationCalls++
|
||||
return tt.result, nil
|
||||
},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
|
||||
if factoryCalls != 1 || applicationCalls != 1 || !strings.Contains(stderr.String(), tt.statusLine) {
|
||||
t.Fatalf("calls/stderr = %d/%d/%q", factoryCalls, applicationCalls, stderr.String())
|
||||
}
|
||||
if tt.wantError {
|
||||
var batchErr app.BatchError
|
||||
if !errors.As(err, &batchErr) {
|
||||
t.Fatalf("Run() error = %v, want BatchError", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var summary batchSummary
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
if summary.Command != commandRun || summary.Status != tt.wantStatus || (summary.Error != "") != tt.wantError {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)
|
||||
|
||||
@@ -5,13 +5,19 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestRunFetchesBundle(t *testing.T) {
|
||||
server := collectionTestServer(t, nil)
|
||||
var currentRequests atomic.Int32
|
||||
server := collectionTestServer(t, nil, func(r *http.Request) {
|
||||
if r.URL.Path == "/conditions/current" {
|
||||
currentRequests.Add(1)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.Defaults()
|
||||
@@ -30,6 +36,9 @@ func TestRunFetchesBundle(t *testing.T) {
|
||||
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||
}
|
||||
if got := currentRequests.Load(); got != 1 {
|
||||
t.Fatalf("conditions/current requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||
@@ -50,7 +59,7 @@ func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunWrapsFetchError(t *testing.T) {
|
||||
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadRequest}, nil)
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.Defaults()
|
||||
@@ -68,9 +77,12 @@ func TestRunWrapsFetchError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||
func collectionTestServer(t *testing.T, statusByPath map[string]int, onRequest func(*http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if onRequest != nil {
|
||||
onRequest(r)
|
||||
}
|
||||
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||
http.Error(w, "upstream failure", status)
|
||||
return
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
const (
|
||||
// SchemaVersion identifies the supported comparison manifest schema.
|
||||
SchemaVersion = "weatherreporter.comparison.v1"
|
||||
SchemaVersion = "weatherreporter.comparison.v2"
|
||||
|
||||
// ManifestFilename is the canonical name of a comparison manifest.
|
||||
ManifestFilename = "comparison.json"
|
||||
@@ -71,6 +71,7 @@ type Result struct {
|
||||
ModelName string `json:"modelName"`
|
||||
Status string `json:"status"`
|
||||
ValidationStatus string `json:"validationStatus,omitempty"`
|
||||
RepairAttempts *int `json:"repairAttempts,omitempty"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
Error *SafeError `json:"error,omitempty"`
|
||||
}
|
||||
@@ -276,14 +277,21 @@ func (manifest Manifest) Validate() error {
|
||||
if strings.TrimSpace(result.ModelName) == "" {
|
||||
return fmt.Errorf("result %d has a blank model name", result.Position)
|
||||
}
|
||||
if result.RepairAttempts != nil && *result.RepairAttempts < 0 {
|
||||
return fmt.Errorf("result %d has negative repair attempts", result.Position)
|
||||
}
|
||||
|
||||
switch result.Status {
|
||||
case StatusSucceeded:
|
||||
succeeded++
|
||||
if result.ValidationStatus != "passed" {
|
||||
if result.ValidationStatus != "passed" || result.RepairAttempts == nil {
|
||||
return fmt.Errorf("successful result %d did not pass validation", result.Position)
|
||||
}
|
||||
if result.Error != nil || !isReportPath(result.ReportPath) {
|
||||
expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("successful result %d has invalid report identity: %w", result.Position, err)
|
||||
}
|
||||
if result.Error != nil || result.ReportPath != expectedPath {
|
||||
return fmt.Errorf("successful result %d has invalid report details", result.Position)
|
||||
}
|
||||
if _, duplicate := reportPaths[result.ReportPath]; duplicate {
|
||||
@@ -298,6 +306,9 @@ func (manifest Manifest) Validate() error {
|
||||
if err := result.Error.validate(); err != nil {
|
||||
return fmt.Errorf("failed result %d: %w", result.Position, err)
|
||||
}
|
||||
if result.ValidationStatus != "" && result.RepairAttempts == nil {
|
||||
return fmt.Errorf("failed result %d has validation without repair provenance", result.Position)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("result %d has unsupported status %q", result.Position, result.Status)
|
||||
}
|
||||
@@ -320,7 +331,7 @@ func (safeError SafeError) validate() error {
|
||||
}
|
||||
|
||||
func isValidationStatus(status string) bool {
|
||||
return status == "" || status == "failed" || status == "skipped"
|
||||
return status == "" || status == "passed" || status == "failed" || status == "skipped"
|
||||
}
|
||||
|
||||
func isSHA256(value string) bool {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package comparison
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -119,7 +118,7 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
|
||||
t.Fatalf("EncodeManifest() error = %v", err)
|
||||
}
|
||||
want := "{\n" +
|
||||
" \"schemaVersion\": \"weatherreporter.comparison.v1\",\n" +
|
||||
" \"schemaVersion\": \"weatherreporter.comparison.v2\",\n" +
|
||||
" \"comparisonId\": \"comparison_daily-2026-08-24\",\n" +
|
||||
" \"startedAt\": \"2026-08-24T12:00:00Z\",\n" +
|
||||
" \"finishedAt\": \"2026-08-24T12:01:00Z\",\n" +
|
||||
@@ -147,6 +146,7 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
|
||||
" \"modelName\": \"gpt-5-mini\",\n" +
|
||||
" \"status\": \"succeeded\",\n" +
|
||||
" \"validationStatus\": \"passed\",\n" +
|
||||
" \"repairAttempts\": 0,\n" +
|
||||
" \"reportPath\": \"01-weather-light.md\"\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
@@ -165,9 +165,9 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
|
||||
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
|
||||
}
|
||||
|
||||
var decoded Manifest
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
decoded, err := decodeManifest(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeManifest() error = %v", err)
|
||||
}
|
||||
if err := decoded.Validate(); err != nil {
|
||||
t.Fatalf("decoded manifest validation error = %v", err)
|
||||
@@ -192,12 +192,19 @@ func TestManifestValidateRejectsInvariants(t *testing.T) {
|
||||
{name: "duplicate profile", mutate: func(manifest *Manifest) { manifest.Results[1].ProfileID = manifest.Results[0].ProfileID }},
|
||||
{name: "unsupported status", mutate: func(manifest *Manifest) { manifest.Results[1].Status = "skipped" }},
|
||||
{name: "successful result without report", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "" }},
|
||||
{name: "arbitrary report name", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "arbitrary.md" }},
|
||||
{name: "wrong report position", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "02-weather-light.md" }},
|
||||
{name: "wrong report ordinal width", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "1-weather-light.md" }},
|
||||
{name: "wrong report profile slug", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "01-weather.md" }},
|
||||
{name: "successful result with error", mutate: func(manifest *Manifest) {
|
||||
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
|
||||
}},
|
||||
{name: "successful result without passed validation", mutate: func(manifest *Manifest) { manifest.Results[0].ValidationStatus = "failed" }},
|
||||
{name: "failed result with report", mutate: func(manifest *Manifest) { manifest.Results[1].ReportPath = "02-weather-deep.md" }},
|
||||
{name: "failed result without error", mutate: func(manifest *Manifest) { manifest.Results[1].Error = nil }},
|
||||
{name: "failed passed result without repair provenance", mutate: func(manifest *Manifest) {
|
||||
manifest.Results[1].ValidationStatus = "passed"
|
||||
}},
|
||||
{name: "traversal report path", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "../report.md" }},
|
||||
{name: "duplicate report path", mutate: func(manifest *Manifest) {
|
||||
manifest.Results[1] = Result{Position: 2, ProfileID: "weather-deep", ModelName: "gpt-5", Status: StatusSucceeded, ValidationStatus: "passed", ReportPath: manifest.Results[0].ReportPath}
|
||||
@@ -219,6 +226,17 @@ func TestManifestValidateRejectsInvariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateAcceptsPostValidationFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifest := validManifest()
|
||||
manifest.Results[1].ValidationStatus = "passed"
|
||||
manifest.Results[1].RepairAttempts = intPtr(0)
|
||||
if err := manifest.Validate(); err != nil {
|
||||
t.Fatalf("Manifest.Validate() rejected post-validation failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogicalBundleValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -247,6 +265,12 @@ func TestLogicalBundleValidate(t *testing.T) {
|
||||
if err := bundle.Validate(); err == nil {
|
||||
t.Fatal("LogicalBundle.Validate() accepted unordered report position")
|
||||
}
|
||||
bundle.Reports[0].Position = 1
|
||||
bundle.Reports[0].Path = "arbitrary.md"
|
||||
bundle.Manifest.Results[0].ReportPath = "arbitrary.md"
|
||||
if err := bundle.Validate(); err == nil {
|
||||
t.Fatal("LogicalBundle.Validate() accepted a noncanonical report path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSHA256AndTruncateErrorMessage(t *testing.T) {
|
||||
@@ -300,6 +324,7 @@ func validManifest() Manifest {
|
||||
ModelName: "gpt-5-mini",
|
||||
Status: StatusSucceeded,
|
||||
ValidationStatus: "passed",
|
||||
RepairAttempts: intPtr(0),
|
||||
ReportPath: "01-weather-light.md",
|
||||
},
|
||||
{
|
||||
@@ -312,3 +337,8 @@ func validManifest() Manifest {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
copy := value
|
||||
return ©
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ const (
|
||||
DestinationNotEmpty DestinationErrorKind = "not_empty"
|
||||
DestinationUnrecognized DestinationErrorKind = "unrecognized"
|
||||
DestinationInspection DestinationErrorKind = "inspection"
|
||||
|
||||
maxComparisonComponentBytes = 255
|
||||
temporaryRandomBytes = 10
|
||||
stagingDirectoryPattern = ".weatherreporter-staging-*"
|
||||
backupDirectoryPattern = ".weatherreporter-backup-*"
|
||||
)
|
||||
|
||||
// DestinationError provides inspectable context without making filesystem
|
||||
@@ -68,6 +73,18 @@ var ErrUnrecognizedBundle = errors.New("unrecognized comparison bundle")
|
||||
|
||||
// PlanDestination performs the read-only comparison destination preflight.
|
||||
func PlanDestination(workingDirectory, target string, replace bool) (DestinationPlan, error) {
|
||||
return planDestination(workingDirectory, target, replace, RecognizeBundle)
|
||||
}
|
||||
|
||||
func planDestination(workingDirectory, target string, replace bool, recognize func(string) (Manifest, error)) (DestinationPlan, error) {
|
||||
plan, err := newDestinationPlan(workingDirectory, target, replace)
|
||||
if err != nil {
|
||||
return DestinationPlan{}, err
|
||||
}
|
||||
return inspectDestination(plan, recognize)
|
||||
}
|
||||
|
||||
func newDestinationPlan(workingDirectory, target string, replace bool) (DestinationPlan, error) {
|
||||
workingDirectory, err := absoluteCleanPath(workingDirectory)
|
||||
if err != nil {
|
||||
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, workingDirectory, err)
|
||||
@@ -82,8 +99,23 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
||||
if target == workingDirectory {
|
||||
return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil)
|
||||
}
|
||||
if err := validateTransactionSiblingNames(target); err != nil {
|
||||
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, target, err)
|
||||
}
|
||||
|
||||
plan := DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}
|
||||
return DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}, nil
|
||||
}
|
||||
|
||||
func reauthorizeDestination(plan DestinationPlan) (DestinationPlan, error) {
|
||||
plan, err := newDestinationPlan(plan.WorkingDirectory, plan.Target, plan.Replace)
|
||||
if err != nil {
|
||||
return DestinationPlan{}, err
|
||||
}
|
||||
return inspectDestination(plan, nil)
|
||||
}
|
||||
|
||||
func inspectDestination(plan DestinationPlan, recognize func(string) (Manifest, error)) (DestinationPlan, error) {
|
||||
target := plan.Target
|
||||
info, err := os.Lstat(target)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -109,16 +141,34 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
||||
plan.state = destinationEmpty
|
||||
return plan, nil
|
||||
}
|
||||
if !replace {
|
||||
if !plan.Replace {
|
||||
return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
|
||||
if recognize != nil {
|
||||
if _, err := recognize(target); err != nil {
|
||||
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
|
||||
}
|
||||
}
|
||||
plan.state = destinationBundle
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func validateTransactionSiblingNames(target string) error {
|
||||
if len(filepath.Base(target)) > maxComparisonComponentBytes {
|
||||
return fmt.Errorf("destination name exceeds the %d-byte limit", maxComparisonComponentBytes)
|
||||
}
|
||||
for _, pattern := range []string{stagingDirectoryPattern, backupDirectoryPattern} {
|
||||
if !temporaryPatternFitsComponentLimit(pattern) {
|
||||
return fmt.Errorf("comparison transaction sibling pattern exceeds the %d-byte limit", maxComparisonComponentBytes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func temporaryPatternFitsComponentLimit(pattern string) bool {
|
||||
return len(strings.Replace(pattern, "*", strings.Repeat("0", temporaryRandomBytes), 1)) <= maxComparisonComponentBytes
|
||||
}
|
||||
|
||||
func absoluteCleanPath(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("path is required")
|
||||
@@ -164,7 +214,25 @@ func newDestinationError(kind DestinationErrorKind, target string, err error) er
|
||||
// RecognizeBundle verifies that directory contains exactly one valid current
|
||||
// comparison bundle. It never follows bundle entries through symlinks.
|
||||
func RecognizeBundle(directory string) (Manifest, error) {
|
||||
info, err := os.Lstat(directory)
|
||||
return recognizeBundle(directory, defaultRecognitionOperations)
|
||||
}
|
||||
|
||||
type recognitionOperations struct {
|
||||
lstat func(string) (os.FileInfo, error)
|
||||
readDir func(string) ([]os.DirEntry, error)
|
||||
open func(string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
var defaultRecognitionOperations = recognitionOperations{
|
||||
lstat: os.Lstat,
|
||||
readDir: os.ReadDir,
|
||||
open: func(path string) (io.ReadCloser, error) {
|
||||
return os.Open(path)
|
||||
},
|
||||
}
|
||||
|
||||
func recognizeBundle(directory string, operations recognitionOperations) (Manifest, error) {
|
||||
info, err := operations.lstat(directory)
|
||||
if err != nil {
|
||||
return Manifest{}, unrecognizedBundleError("inspect directory", err)
|
||||
}
|
||||
@@ -172,11 +240,11 @@ func RecognizeBundle(directory string) (Manifest, error) {
|
||||
return Manifest{}, unrecognizedBundleError("directory is not a real directory", nil)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(directory)
|
||||
entries, err := operations.readDir(directory)
|
||||
if err != nil {
|
||||
return Manifest{}, unrecognizedBundleError("read directory", err)
|
||||
}
|
||||
manifestData, err := readBundleFile(directory, ManifestFilename)
|
||||
manifestData, err := readBundleFile(directory, ManifestFilename, operations)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
@@ -204,12 +272,15 @@ func RecognizeBundle(directory string) (Manifest, error) {
|
||||
if _, ok := expected[entry.Name()]; !ok {
|
||||
return Manifest{}, unrecognizedBundleError("directory has an undeclared entry", nil)
|
||||
}
|
||||
if _, err := readBundleFile(directory, entry.Name()); err != nil {
|
||||
if entry.Name() == ManifestFilename || entry.Name() == DataPackageFilename {
|
||||
continue
|
||||
}
|
||||
if err := inspectBundleFile(directory, entry.Name(), operations); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
}
|
||||
|
||||
dataPackage, err := readBundleFile(directory, DataPackageFilename)
|
||||
dataPackage, err := readBundleFile(directory, DataPackageFilename, operations)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
@@ -219,26 +290,53 @@ func RecognizeBundle(directory string) (Manifest, error) {
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func readBundleFile(directory, name string) ([]byte, error) {
|
||||
if !isArtifactBasename(name) {
|
||||
return nil, unrecognizedBundleError("bundle entry name is unsafe", nil)
|
||||
}
|
||||
filePath := filepath.Join(directory, name)
|
||||
info, err := os.Lstat(filePath)
|
||||
func readBundleFile(directory, name string, operations recognitionOperations) ([]byte, error) {
|
||||
file, err := openBundleFile(directory, name, operations)
|
||||
if err != nil {
|
||||
return nil, unrecognizedBundleError("inspect bundle entry", err)
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, unrecognizedBundleError("bundle entry is not a regular file", nil)
|
||||
}
|
||||
data, err := os.ReadFile(filePath)
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, unrecognizedBundleError("read bundle entry", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func inspectBundleFile(directory, name string, operations recognitionOperations) error {
|
||||
file, err := openBundleFile(directory, name, operations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return unrecognizedBundleError("close bundle entry", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openBundleFile(directory, name string, operations recognitionOperations) (io.ReadCloser, error) {
|
||||
if !isArtifactBasename(name) {
|
||||
return nil, unrecognizedBundleError("bundle entry name is unsafe", nil)
|
||||
}
|
||||
filePath := filepath.Join(directory, name)
|
||||
info, err := operations.lstat(filePath)
|
||||
if err != nil {
|
||||
return nil, unrecognizedBundleError("inspect bundle entry", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, unrecognizedBundleError("bundle entry is not a regular file", nil)
|
||||
}
|
||||
file, err := operations.open(filePath)
|
||||
if err != nil {
|
||||
return nil, unrecognizedBundleError("read bundle entry", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func decodeManifest(data []byte) (Manifest, error) {
|
||||
if err := validateManifestJSON(data); err != nil {
|
||||
return Manifest{}, unrecognizedBundleError("decode manifest", err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest Manifest
|
||||
@@ -255,6 +353,240 @@ func decodeManifest(data []byte) (Manifest, error) {
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
var manifestFields = map[string]jsonValueValidator{
|
||||
"schemaVersion": nil,
|
||||
"comparisonId": nil,
|
||||
"startedAt": nil,
|
||||
"finishedAt": nil,
|
||||
"reportId": nil,
|
||||
"validPeriod": validateValidPeriodJSON,
|
||||
"timezone": nil,
|
||||
"promptId": nil,
|
||||
"promptVersion": nil,
|
||||
"promptHash": nil,
|
||||
"dataPackage": validateDataPackageJSON,
|
||||
"total": nil,
|
||||
"succeeded": nil,
|
||||
"failed": nil,
|
||||
"results": validateResultsJSON,
|
||||
}
|
||||
|
||||
var validPeriodFields = map[string]jsonValueValidator{
|
||||
"start": nil,
|
||||
"end": nil,
|
||||
}
|
||||
|
||||
var dataPackageFields = map[string]jsonValueValidator{
|
||||
"path": nil,
|
||||
"sha256": nil,
|
||||
}
|
||||
|
||||
var resultFields = map[string]jsonValueValidator{
|
||||
"position": nil,
|
||||
"profileId": nil,
|
||||
"backendId": nil,
|
||||
"modelName": nil,
|
||||
"status": nil,
|
||||
"validationStatus": nil,
|
||||
"repairAttempts": validateRepairAttemptsJSON,
|
||||
"reportPath": nil,
|
||||
"error": validateSafeErrorJSON,
|
||||
}
|
||||
|
||||
var safeErrorFields = map[string]jsonValueValidator{
|
||||
"category": nil,
|
||||
"message": nil,
|
||||
}
|
||||
|
||||
type jsonValueValidator func(*json.Decoder) error
|
||||
|
||||
func validateManifestJSON(data []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
if err := validateJSONObject(decoder, manifestFields); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("manifest has multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateValidPeriodJSON(decoder *json.Decoder) error {
|
||||
return validateJSONObject(decoder, validPeriodFields)
|
||||
}
|
||||
|
||||
func validateDataPackageJSON(decoder *json.Decoder) error {
|
||||
return validateJSONObject(decoder, dataPackageFields)
|
||||
}
|
||||
|
||||
func validateResultsJSON(decoder *json.Decoder) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return fmt.Errorf("results must be an array")
|
||||
}
|
||||
for decoder.More() {
|
||||
if err := validateOrderedJSONObject(decoder, resultFields, []string{"position", "profileId", "backendId", "modelName", "status", "validationStatus", "repairAttempts", "reportPath", "error"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
token, err = decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' {
|
||||
return fmt.Errorf("results has an invalid array terminator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOrderedJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator, order []string) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return fmt.Errorf("manifest value must be an object")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
last := -1
|
||||
for decoder.More() {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, ok := token.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("manifest field name is invalid")
|
||||
}
|
||||
validator, known := fields[name]
|
||||
if !known {
|
||||
return fmt.Errorf("manifest field %q is not canonical", name)
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return fmt.Errorf("manifest field %q is duplicated", name)
|
||||
}
|
||||
position := -1
|
||||
for index, candidate := range order {
|
||||
if candidate == name {
|
||||
position = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if position <= last {
|
||||
return fmt.Errorf("manifest field %q is out of canonical order", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
last = position
|
||||
if validator == nil {
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validator(decoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
token, err = decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
|
||||
return fmt.Errorf("manifest object has an invalid terminator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSafeErrorJSON(decoder *json.Decoder) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return fmt.Errorf("error must be an object")
|
||||
}
|
||||
return validateJSONObjectBody(decoder, safeErrorFields)
|
||||
}
|
||||
|
||||
func validateRepairAttemptsJSON(decoder *json.Decoder) error {
|
||||
var raw json.RawMessage
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return fmt.Errorf("repairAttempts must be an integer")
|
||||
}
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value < 0 {
|
||||
return fmt.Errorf("repairAttempts must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return fmt.Errorf("manifest value must be an object")
|
||||
}
|
||||
return validateJSONObjectBody(decoder, fields)
|
||||
}
|
||||
|
||||
func validateJSONObjectBody(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
for decoder.More() {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, ok := token.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("manifest field name is invalid")
|
||||
}
|
||||
validator, known := fields[name]
|
||||
if !known {
|
||||
return fmt.Errorf("manifest field %q is not canonical", name)
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return fmt.Errorf("manifest field %q is duplicated", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if validator == nil {
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validator(decoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
|
||||
return fmt.Errorf("manifest object has an invalid terminator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unrecognizedBundleError(action string, err error) error {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action)
|
||||
@@ -262,22 +594,44 @@ func unrecognizedBundleError(action string, err error) error {
|
||||
return fmt.Errorf("%w: %s: %v", ErrUnrecognizedBundle, action, err)
|
||||
}
|
||||
|
||||
// BackupRecoveryState describes what remains of a prior bundle after cleanup
|
||||
// reports an error.
|
||||
type BackupRecoveryState string
|
||||
|
||||
const (
|
||||
BackupRecoveryComplete BackupRecoveryState = "complete"
|
||||
BackupRecoveryPartial BackupRecoveryState = "partial"
|
||||
BackupRecoveryAbsent BackupRecoveryState = "absent"
|
||||
BackupRecoveryUnknown BackupRecoveryState = "unknown"
|
||||
)
|
||||
|
||||
// PublicationResult describes the durable state of a publication attempt.
|
||||
type PublicationResult struct {
|
||||
Committed bool
|
||||
RetainedBackupPath string
|
||||
Committed bool
|
||||
RecoveryState BackupRecoveryState
|
||||
RecoveryPath string
|
||||
}
|
||||
|
||||
// PublicationCleanupError reports that a committed bundle could not remove its
|
||||
// prior sibling backup. The new bundle remains installed and the backup path
|
||||
// is retained for operator recovery.
|
||||
// PublicationCleanupError reports that a committed bundle could not completely
|
||||
// remove its prior sibling backup. The new bundle remains installed; recovery
|
||||
// fields describe the state observed after cleanup failed.
|
||||
type PublicationCleanupError struct {
|
||||
RetainedBackupPath string
|
||||
Err error
|
||||
RecoveryState BackupRecoveryState
|
||||
RecoveryPath string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *PublicationCleanupError) Error() string {
|
||||
return fmt.Sprintf("remove comparison backup %q: %v", err.RetainedBackupPath, err.Err)
|
||||
switch err.RecoveryState {
|
||||
case BackupRecoveryComplete:
|
||||
return fmt.Sprintf("remove comparison backup %q: %v; complete recovery bundle remains", err.RecoveryPath, err.Err)
|
||||
case BackupRecoveryPartial:
|
||||
return fmt.Sprintf("remove comparison backup %q: %v; partial remnants remain", err.RecoveryPath, err.Err)
|
||||
case BackupRecoveryAbsent:
|
||||
return fmt.Sprintf("remove comparison backup: %v; no recovery bundle remains", err.Err)
|
||||
default:
|
||||
return fmt.Sprintf("remove comparison backup: %v; recovery state is unknown", err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func (err *PublicationCleanupError) Unwrap() error {
|
||||
@@ -294,6 +648,7 @@ type publishOperations struct {
|
||||
rename func(string, string) error
|
||||
removeAll func(string) error
|
||||
beforeCommit func()
|
||||
recognize func(string) (Manifest, error)
|
||||
}
|
||||
|
||||
func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, operations publishOperations) (PublicationResult, error) {
|
||||
@@ -303,6 +658,9 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
if operations.removeAll == nil {
|
||||
operations.removeAll = os.RemoveAll
|
||||
}
|
||||
if operations.recognize == nil {
|
||||
operations.recognize = RecognizeBundle
|
||||
}
|
||||
if err := bundle.Validate(); err != nil {
|
||||
return PublicationResult{}, fmt.Errorf("validate comparison bundle: %w", err)
|
||||
}
|
||||
@@ -313,7 +671,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PublicationResult{}, err
|
||||
}
|
||||
plan, err = PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
|
||||
plan, err = reauthorizeDestination(plan)
|
||||
if err != nil {
|
||||
return PublicationResult{}, err
|
||||
}
|
||||
@@ -321,7 +679,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
if err := os.MkdirAll(filepath.Dir(plan.Target), 0o755); err != nil {
|
||||
return PublicationResult{}, fmt.Errorf("create comparison destination parent %q: %w", filepath.Dir(plan.Target), err)
|
||||
}
|
||||
temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), "."+filepath.Base(plan.Target)+".staging-")
|
||||
temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), stagingDirectoryPattern)
|
||||
if err != nil {
|
||||
return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err)
|
||||
}
|
||||
@@ -339,7 +697,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
return PublicationResult{}, err
|
||||
}
|
||||
|
||||
currentPlan, err := PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
|
||||
currentPlan, err := reauthorizeDestination(plan)
|
||||
if err != nil {
|
||||
return PublicationResult{}, err
|
||||
}
|
||||
@@ -357,14 +715,17 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
return PublicationResult{Committed: true}, nil
|
||||
}
|
||||
|
||||
backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), "."+filepath.Base(currentPlan.Target)+".backup-")
|
||||
backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), backupDirectoryPattern)
|
||||
if err != nil {
|
||||
return PublicationResult{}, err
|
||||
}
|
||||
if err := operations.rename(currentPlan.Target, backupDirectory); err != nil {
|
||||
return PublicationResult{}, fmt.Errorf("back up comparison destination %q: %w", currentPlan.Target, err)
|
||||
}
|
||||
if err := authorizeMovedDestination(currentPlan, backupDirectory); err != nil {
|
||||
if err := authorizeMovedDestination(currentPlan, backupDirectory, operations.recognize); err != nil {
|
||||
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
|
||||
}
|
||||
if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil {
|
||||
@@ -372,14 +733,28 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
||||
}
|
||||
temporaryDirectory = ""
|
||||
if err := operations.removeAll(backupDirectory); err != nil {
|
||||
cleanupErr := &PublicationCleanupError{RetainedBackupPath: backupDirectory, Err: err}
|
||||
return PublicationResult{Committed: true, RetainedBackupPath: backupDirectory}, cleanupErr
|
||||
recoveryState, recoveryPath := inspectBackupRecovery(backupDirectory)
|
||||
cleanupErr := &PublicationCleanupError{RecoveryState: recoveryState, RecoveryPath: recoveryPath, Err: err}
|
||||
return PublicationResult{Committed: true, RecoveryState: recoveryState, RecoveryPath: recoveryPath}, cleanupErr
|
||||
}
|
||||
return PublicationResult{Committed: true}, nil
|
||||
}
|
||||
|
||||
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string) error {
|
||||
backupPlan, err := PlanDestination(plan.WorkingDirectory, backupDirectory, plan.Replace)
|
||||
func inspectBackupRecovery(backupDirectory string) (BackupRecoveryState, string) {
|
||||
if _, err := os.Lstat(backupDirectory); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return BackupRecoveryAbsent, ""
|
||||
}
|
||||
return BackupRecoveryUnknown, ""
|
||||
}
|
||||
if _, err := RecognizeBundle(backupDirectory); err == nil {
|
||||
return BackupRecoveryComplete, backupDirectory
|
||||
}
|
||||
return BackupRecoveryPartial, backupDirectory
|
||||
}
|
||||
|
||||
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string, recognize func(string) (Manifest, error)) error {
|
||||
backupPlan, err := planDestination(plan.WorkingDirectory, backupDirectory, plan.Replace, recognize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authorize moved comparison destination %q: %w", backupDirectory, err)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package comparison
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPlanDestination(t *testing.T) {
|
||||
@@ -47,18 +52,16 @@ func TestPlanDestination(t *testing.T) {
|
||||
assertDestinationErrorKind(t, workingDirectory, string(os.PathSeparator), false, DestinationFilesystemRoot)
|
||||
assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath)
|
||||
|
||||
link := filepath.Join(workingDirectory, "link")
|
||||
if err := os.Symlink(empty, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
|
||||
t.Run("symbolic links", func(t *testing.T) {
|
||||
link := filepath.Join(workingDirectory, "link")
|
||||
testutil.RequireSymlink(t, empty, link)
|
||||
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
|
||||
|
||||
dangling := filepath.Join(workingDirectory, "dangling")
|
||||
if err := os.Symlink(filepath.Join(workingDirectory, "missing"), dangling); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
|
||||
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
|
||||
dangling := filepath.Join(workingDirectory, "dangling")
|
||||
testutil.RequireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling)
|
||||
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
|
||||
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
|
||||
})
|
||||
|
||||
nonempty := filepath.Join(workingDirectory, "nonempty")
|
||||
if err := os.Mkdir(nonempty, 0o755); err != nil {
|
||||
@@ -116,9 +119,7 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(directory, DataPackageFilename), path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.RequireSymlink(t, filepath.Join(directory, DataPackageFilename), path)
|
||||
}},
|
||||
{name: "digest mismatch", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
@@ -138,6 +139,31 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "duplicate manifest field", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
appendManifestField(t, directory, `"schemaVersion": "weatherreporter.comparison.v1"`)
|
||||
}},
|
||||
{name: "case variant manifest field", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
appendManifestField(t, directory, `"SchemaVersion": "weatherreporter.comparison.v1"`)
|
||||
}},
|
||||
{name: "null repair attempts", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, ManifestFilename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := []byte(" \"status\": \"failed\",\n \"error\": {")
|
||||
after := []byte(" \"status\": \"failed\",\n \"repairAttempts\": null,\n \"error\": {")
|
||||
data = bytes.Replace(data, before, after, 1)
|
||||
if bytes.Equal(data, readFile(t, path)) {
|
||||
t.Fatal("failed to add null repairAttempts")
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "traversal report path", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, ManifestFilename)
|
||||
@@ -158,6 +184,29 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "noncanonical report path", mutate: func(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, ManifestFilename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(directory, manifest.Results[0].ReportPath), filepath.Join(directory, "arbitrary.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest.Results[0].ReportPath = "arbitrary.md"
|
||||
data, err = json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -171,6 +220,43 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRepairAttemptsJSONRejectsNonIntegerValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"null", "-1", "1.5", `"1"`, "9223372036854775808", "{"} {
|
||||
value := value
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := validateRepairAttemptsJSON(json.NewDecoder(strings.NewReader(value))); err == nil {
|
||||
t.Fatalf("validateRepairAttemptsJSON(%q) error = nil", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRepairAttemptsJSONAcceptsNonnegativeIntegers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"0", "1"} {
|
||||
if err := validateRepairAttemptsJSON(json.NewDecoder(strings.NewReader(value))); err != nil {
|
||||
t.Fatalf("validateRepairAttemptsJSON(%q) error = %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendManifestField(t *testing.T, directory, field string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, ManifestFilename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = append(data[:len(data)-2], []byte(",\n "+field+"\n}\n")...)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
|
||||
directory := publishTestBundle(t, testBundle())
|
||||
plan, err := PlanDestination(filepath.Dir(directory), directory, true)
|
||||
@@ -182,6 +268,75 @@ func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeBundleReadsOnlyRequiredArtifactContents(t *testing.T) {
|
||||
for _, reportCount := range []int{2, 12} {
|
||||
t.Run(fmt.Sprintf("%d reports", reportCount), func(t *testing.T) {
|
||||
bundle := testBundleWithReports(t, reportCount)
|
||||
directory := publishTestBundle(t, bundle)
|
||||
bytesRead := make(map[string]int)
|
||||
opens := make(map[string]int)
|
||||
operations := defaultRecognitionOperations
|
||||
operations.open = func(path string) (io.ReadCloser, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
opens[name]++
|
||||
return &countingReadCloser{ReadCloser: file, count: func(n int) {
|
||||
bytesRead[name] += n
|
||||
}}, nil
|
||||
}
|
||||
|
||||
if _, err := recognizeBundle(directory, operations); err != nil {
|
||||
t.Fatalf("recognizeBundle() error = %v", err)
|
||||
}
|
||||
for _, name := range []string{ManifestFilename, DataPackageFilename} {
|
||||
data := readFile(t, filepath.Join(directory, name))
|
||||
if opens[name] != 1 || bytesRead[name] != len(data) {
|
||||
t.Fatalf("%s opens/bytes = %d/%d, want 1/%d", name, opens[name], bytesRead[name], len(data))
|
||||
}
|
||||
}
|
||||
for _, report := range bundle.Reports {
|
||||
if opens[report.Path] != 1 || bytesRead[report.Path] != 0 {
|
||||
t.Fatalf("report %s opens/bytes = %d/%d, want 1/0", report.Path, opens[report.Path], bytesRead[report.Path])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementUsesEarlyAndFinalRecognition(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
bundle := testBundleWithReports(t, 12)
|
||||
initialPlan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), initialPlan, bundle); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recognitions := 0
|
||||
recognize := func(directory string) (Manifest, error) {
|
||||
recognitions++
|
||||
return RecognizeBundle(directory)
|
||||
}
|
||||
plan, err := planDestination(workingDirectory, target, true, recognize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := publish(context.Background(), plan, bundle, publishOperations{
|
||||
rename: os.Rename, removeAll: os.RemoveAll, recognize: recognize,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recognitions != 2 {
|
||||
t.Fatalf("full bundle recognitions = %d, want early and final authorization", recognitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishReauthorizesMovedDestination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -254,9 +409,7 @@ func TestPublishReauthorizesMovedDestination(t *testing.T) {
|
||||
},
|
||||
mutate: func(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink("unrelated-target", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.RequireSymlink(t, "unrelated-target", target)
|
||||
},
|
||||
verify: func(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
@@ -462,6 +615,49 @@ func TestPublishReplacesEmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonDestinationPreflightsTransactionSiblingNames(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
missingParent := filepath.Join(workingDirectory, "missing")
|
||||
target := filepath.Join(missingParent, strings.Repeat("a", maxComparisonComponentBytes+1))
|
||||
if _, err := PlanDestination(workingDirectory, target, false); err == nil {
|
||||
t.Fatal("PlanDestination() accepted an overlong destination name")
|
||||
}
|
||||
if _, err := os.Lstat(missingParent); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing parent stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSupportsLongestDestinationComponent(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
parent := filepath.Join(workingDirectory, "nested")
|
||||
target := filepath.Join(parent, strings.Repeat("a", maxComparisonComponentBytes))
|
||||
|
||||
plan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanDestination() error = %v", err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), plan, testBundle()); err != nil {
|
||||
t.Fatalf("Publish() error = %v", err)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
t.Fatalf("RecognizeBundle() error = %v", err)
|
||||
}
|
||||
|
||||
next := testBundle()
|
||||
next.Reports[0].Markdown = []byte("# Replacement\n")
|
||||
plan, err = PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanDestination(replace) error = %v", err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), plan, next); err != nil {
|
||||
t.Fatalf("Publish(replace) error = %v", err)
|
||||
}
|
||||
if data := readFile(t, filepath.Join(target, "01-weather-light.md")); string(data) != "# Replacement\n" {
|
||||
t.Fatalf("replacement report = %q", data)
|
||||
}
|
||||
assertOnlyDestinationEntry(t, parent, filepath.Base(target))
|
||||
}
|
||||
|
||||
func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
@@ -490,7 +686,7 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
||||
},
|
||||
})
|
||||
var cleanupErr *PublicationCleanupError
|
||||
if !result.Committed || result.RetainedBackupPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RetainedBackupPath != backupPath || !errors.Is(err, cleanupCause) {
|
||||
if !result.Committed || result.RecoveryState != BackupRecoveryComplete || result.RecoveryPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryComplete || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
|
||||
t.Fatalf("publish() result/error = %#v/%v", result, err)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
@@ -505,6 +701,82 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishReportsPartialRecoveryAfterBackupCleanupFailure(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
initialPlan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCause := errors.New("backup removal failed")
|
||||
var backupPath string
|
||||
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
|
||||
rename: os.Rename,
|
||||
removeAll: func(path string) error {
|
||||
backupPath = path
|
||||
if err := os.Remove(filepath.Join(path, ManifestFilename)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cleanupCause
|
||||
},
|
||||
})
|
||||
var cleanupErr *PublicationCleanupError
|
||||
if !result.Committed || result.RecoveryState != BackupRecoveryPartial || result.RecoveryPath != backupPath || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryPartial || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
|
||||
t.Fatalf("publish() result/error = %#v/%v", result, err)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
t.Fatalf("new bundle recognition error = %v", err)
|
||||
}
|
||||
if _, err := RecognizeBundle(backupPath); err == nil {
|
||||
t.Fatal("partially removed backup was recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishReportsAbsentRecoveryAfterBackupCleanupFailure(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
initialPlan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCause := errors.New("backup removal failed")
|
||||
var backupPath string
|
||||
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
|
||||
rename: os.Rename,
|
||||
removeAll: func(path string) error {
|
||||
backupPath = path
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cleanupCause
|
||||
},
|
||||
})
|
||||
var cleanupErr *PublicationCleanupError
|
||||
if !result.Committed || result.RecoveryState != BackupRecoveryAbsent || result.RecoveryPath != "" || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryAbsent || cleanupErr.RecoveryPath != "" || !errors.Is(err, cleanupCause) {
|
||||
t.Fatalf("publish() result/error = %#v/%v", result, err)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
t.Fatalf("new bundle recognition error = %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(backupPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backup stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
@@ -598,6 +870,154 @@ func TestPublishCancellationBeforeCommitLeavesNoDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRestoresPriorDestinationWhenCanceledAfterBackup(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, workingDirectory, target string)
|
||||
}{
|
||||
{
|
||||
name: "empty directory",
|
||||
prepare: func(t *testing.T, _, target string) {
|
||||
t.Helper()
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recognized bundle",
|
||||
prepare: func(t *testing.T, workingDirectory, target string) {
|
||||
t.Helper()
|
||||
plan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), plan, testBundle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
test.prepare(t, workingDirectory, target)
|
||||
before := directorySnapshot(t, target)
|
||||
|
||||
plan, err := PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
next := testBundle()
|
||||
next.Reports[0].Markdown = []byte("# Replacement\n")
|
||||
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldPath == target {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
if publication.Committed || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("publish() result/error = %#v/%v, want canceled uncommitted publication", publication, err)
|
||||
}
|
||||
if after := directorySnapshot(t, target); !equalSnapshots(before, after) {
|
||||
t.Fatalf("restored destination = %#v, want %#v", after, before)
|
||||
}
|
||||
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRetainsBackupWhenCancellationRestorationFails(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
initialPlan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
restoreCause := errors.New("restore failed")
|
||||
var backupPath string
|
||||
calls := 0
|
||||
publication, err := publish(ctx, plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return restoreCause
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if calls == 1 {
|
||||
backupPath = newPath
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
if publication.Committed || !errors.Is(err, context.Canceled) || !errors.Is(err, restoreCause) {
|
||||
t.Fatalf("publish() result/error = %#v/%v, want cancellation and restoration failure", publication, err)
|
||||
}
|
||||
if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr)
|
||||
}
|
||||
if _, statErr := os.Lstat(target); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("destination stat error = %v, want not exist", statErr)
|
||||
}
|
||||
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(backupPath))
|
||||
}
|
||||
|
||||
func TestPublishReturnsCommittedBundleWhenCanceledAfterInstall(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||
initialPlan, err := PlanDestination(workingDirectory, target, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := testBundle()
|
||||
next.Reports[0].Markdown = []byte("# Next\n")
|
||||
plan, err := PlanDestination(workingDirectory, target, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if newPath == target && oldPath != target {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
if !publication.Committed || err != nil {
|
||||
t.Fatalf("publish() result/error = %#v/%v, want committed bundle", publication, err)
|
||||
}
|
||||
if err := ctx.Err(); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("context error = %v, want cancellation", err)
|
||||
}
|
||||
if got := string(readFile(t, filepath.Join(target, next.Reports[0].Path))); got != string(next.Reports[0].Markdown) {
|
||||
t.Fatalf("published report = %q, want %q", got, next.Reports[0].Markdown)
|
||||
}
|
||||
if _, err := RecognizeBundle(target); err != nil {
|
||||
t.Fatalf("committed bundle recognition error = %v", err)
|
||||
}
|
||||
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
|
||||
}
|
||||
|
||||
func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) {
|
||||
t.Helper()
|
||||
_, err := PlanDestination(workingDirectory, target, replace)
|
||||
@@ -683,6 +1103,41 @@ func testBundle() LogicalBundle {
|
||||
}
|
||||
}
|
||||
|
||||
func testBundleWithReports(t *testing.T, count int) LogicalBundle {
|
||||
t.Helper()
|
||||
dataPackage := []byte("report: daily\n")
|
||||
manifest := validManifest()
|
||||
manifest.Total, manifest.Succeeded, manifest.Failed = count, count, 0
|
||||
manifest.Results = make([]Result, count)
|
||||
reports := make([]BundleReport, count)
|
||||
for i := range manifest.Results {
|
||||
position := i + 1
|
||||
profileID := fmt.Sprintf("weather-%d", position)
|
||||
path, err := ReportFilename(position, count, profileID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest.Results[i] = Result{
|
||||
Position: position, ProfileID: profileID, ModelName: "gpt-5-mini",
|
||||
Status: StatusSucceeded, ValidationStatus: "passed", RepairAttempts: intPtr(0), ReportPath: path,
|
||||
}
|
||||
reports[i] = BundleReport{Position: position, Path: path, Markdown: []byte("# Daily\n")}
|
||||
}
|
||||
manifest.DataPackage.SHA256 = SHA256(dataPackage)
|
||||
return LogicalBundle{Manifest: manifest, DataPackage: dataPackage, Reports: reports}
|
||||
}
|
||||
|
||||
type countingReadCloser struct {
|
||||
io.ReadCloser
|
||||
count func(int)
|
||||
}
|
||||
|
||||
func (reader *countingReadCloser) Read(buffer []byte) (int, error) {
|
||||
n, err := reader.ReadCloser.Read(buffer)
|
||||
reader.count(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func assertMode(t *testing.T, path string, want os.FileMode) {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
|
||||
@@ -21,6 +21,33 @@ const (
|
||||
NotifyFailureError NotifyFailurePolicy = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
MissingSourceObservations = "observations"
|
||||
MissingSourceCurrent = "current"
|
||||
MissingSourceNarrative = "narrative"
|
||||
MissingSourceAlerts = "alerts"
|
||||
MissingSourceDiscussion = "discussion"
|
||||
MissingSourceWeatherStory = "weather_story"
|
||||
MissingSourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
)
|
||||
|
||||
var supportedMissingSources = map[string]struct{}{
|
||||
MissingSourceObservations: {},
|
||||
MissingSourceCurrent: {},
|
||||
MissingSourceNarrative: {},
|
||||
MissingSourceAlerts: {},
|
||||
MissingSourceDiscussion: {},
|
||||
MissingSourceWeatherStory: {},
|
||||
MissingSourceSPCConvectiveOutlooks: {},
|
||||
}
|
||||
|
||||
// IsSupportedMissingSource reports whether source accepts a missing-source
|
||||
// policy override. Required sources, including hourly, are not configurable.
|
||||
func IsSupportedMissingSource(source string) bool {
|
||||
_, ok := supportedMissingSources[source]
|
||||
return ok
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||
Location LocationConfig `yaml:"location"`
|
||||
@@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
}
|
||||
|
||||
func (c ReportDistributorConfig) PathTemplatesSet() bool {
|
||||
return c.pathTemplatesSet
|
||||
return c.pathTemplatesSet || c.PathTemplates != nil
|
||||
}
|
||||
|
||||
func (c ReportConfig) deterministicModulesConfigured() bool {
|
||||
return c.deterministicModulesSet || c.DeterministicModules != nil
|
||||
}
|
||||
|
||||
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -82,6 +83,70 @@ func TestDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaypartNamesMustHaveDistinctCanonicalIdentities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
names []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "exact duplicate", names: []string{"morning", "morning"}, wantErr: "conflicts with"},
|
||||
{name: "case-only duplicate", names: []string{"morning", "MORNING"}, wantErr: "conflicts with"},
|
||||
{name: "punctuation-normalized duplicate", names: []string{"morning", "morning!"}, wantErr: "conflicts with"},
|
||||
{name: "distinct Unicode names", names: []string{"mañana", "manana"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.Dayparts = make([]DaypartConfig, 0, len(tt.names))
|
||||
for _, name := range tt.names {
|
||||
cfg.Dayparts = append(cfg.Dayparts, DaypartConfig{Name: name, Start: "06:00", End: "12:00"})
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherAPIBaseURLValidation(t *testing.T) {
|
||||
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 := Defaults()
|
||||
cfg.WeatherAPI.BaseURL = tt.baseURL
|
||||
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputDirectoryLoading(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -533,6 +598,35 @@ func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportModuleOverridesCanonicalizesTypedPointerOptions(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
options := &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}
|
||||
cfg.Reports = map[string]ReportConfig{
|
||||
"daily": {
|
||||
DeterministicModules: []ModuleConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.AreaForecastDiscussion, Options: options},
|
||||
},
|
||||
deterministicModulesSet: true,
|
||||
},
|
||||
}
|
||||
|
||||
overrides, err := cfg.ReportModuleOverrides()
|
||||
if err != nil {
|
||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||
}
|
||||
got, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
|
||||
}
|
||||
if !reflect.DeepEqual(got, *options) {
|
||||
t.Fatalf("override options = %#v, want %#v", got, *options)
|
||||
}
|
||||
if cfg.Reports["daily"].DeterministicModules[1].Options != options {
|
||||
t.Fatalf("config options = %#v, want original pointer %#v", cfg.Reports["daily"].DeterministicModules[1].Options, options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||
tests := []struct {
|
||||
@@ -1066,6 +1160,44 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePoliciesOnlyAllowSupportedOptionalSources(t *testing.T) {
|
||||
for _, source := range []string{
|
||||
MissingSourceObservations,
|
||||
MissingSourceCurrent,
|
||||
MissingSourceNarrative,
|
||||
MissingSourceAlerts,
|
||||
MissingSourceDiscussion,
|
||||
MissingSourceWeatherStory,
|
||||
MissingSourceSPCConvectiveOutlooks,
|
||||
} {
|
||||
t.Run("supported_"+source, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceNone}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadFile(writeConfig(t, "missing_source:\n sources:\n "+source+": none\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
if loaded.MissingSource.Sources[source] != MissingSourceNone {
|
||||
t.Fatalf("loaded source policy = %q, want none", loaded.MissingSource.Sources[source])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, source := range []string{"alert", "unknown", "hourly", ""} {
|
||||
t.Run("unsupported_"+source, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceWarn}
|
||||
err := Validate(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing_source.sources") {
|
||||
t.Fatalf("Validate() error = %v, want unsupported source error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesOverrides(t *testing.T) {
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||
if err != nil {
|
||||
@@ -1180,9 +1312,10 @@ func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
|
||||
|
||||
func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
wantAbsent string
|
||||
}{
|
||||
{
|
||||
name: "Endpoint",
|
||||
@@ -1191,6 +1324,35 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
},
|
||||
{
|
||||
name: "UnsupportedEndpointScheme",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
},
|
||||
{
|
||||
name: "EndpointUserinfo",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Endpoint = "https://userinfo-secret@distributor.example.test"
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
wantAbsent: "userinfo-secret",
|
||||
},
|
||||
{
|
||||
name: "EndpointQuery",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test?preview=1"
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
},
|
||||
{
|
||||
name: "EndpointFragment",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test#status"
|
||||
},
|
||||
wantErr: "notify.distributor.endpoint",
|
||||
},
|
||||
{
|
||||
name: "TokenEnvEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -1247,6 +1409,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
},
|
||||
wantErr: "notify.distributor.bundle_id_template",
|
||||
},
|
||||
{
|
||||
name: "BundleTemplateRenderedEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.BundleIDTemplate = " "
|
||||
},
|
||||
wantErr: "notify.distributor.bundle_id_template",
|
||||
},
|
||||
{
|
||||
name: "IdempotencyTemplate",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -1254,6 +1423,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
},
|
||||
wantErr: "notify.distributor.idempotency_key_template",
|
||||
},
|
||||
{
|
||||
name: "IdempotencyTemplateRenderedEmpty",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.IdempotencyKeyTemplate = " "
|
||||
},
|
||||
wantErr: "notify.distributor.idempotency_key_template",
|
||||
},
|
||||
{
|
||||
name: "BatchTemplate",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -1277,6 +1453,27 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if tt.wantAbsent != "" && strings.Contains(err.Error(), tt.wantAbsent) {
|
||||
t.Fatalf("error = %q, must not contain endpoint userinfo", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabledDistributorNotifyAcceptsHTTPBasePaths(t *testing.T) {
|
||||
for _, endpoint := range []string{
|
||||
"http://distributor.example.test/archive",
|
||||
"https://distributor.example.test/archive/",
|
||||
} {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.Endpoint = endpoint
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1529,43 +1726,59 @@ func TestDistributorBatchTemplateRendering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorBatchTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
|
||||
func TestDistributorTemplateRejectsUnknownVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
render func(string) error
|
||||
}{
|
||||
{name: "Unknown", template: "{report_id}"},
|
||||
{name: "Unclosed", template: "{batch"},
|
||||
{name: "Unopened", template: "batch}"},
|
||||
{name: "Empty", template: "{}"},
|
||||
{
|
||||
name: "SingleReport",
|
||||
template: "{unknown}",
|
||||
render: func(template string) error {
|
||||
_, err := RenderDistributorBundleID(template, DistributorTemplateValues{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Batch",
|
||||
template: "{report_id}",
|
||||
render: func(template string) error {
|
||||
_, err := RenderDistributorBatchBundleID(template, DistributorBatchTemplateValues{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{})
|
||||
err := tt.render(tt.template)
|
||||
if err == nil {
|
||||
t.Fatal("RenderDistributorBatchBundleID() error = nil, want error")
|
||||
t.Fatal("rendering error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
|
||||
func TestDistributorTemplateParserRejectsMalformedVariables(t *testing.T) {
|
||||
const name = "notify.distributor.bundle_id_template"
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "Unknown", template: "{unknown}"},
|
||||
{name: "Unclosed", template: "{location_id"},
|
||||
{name: "Unopened", template: "location_id}"},
|
||||
{name: "Empty", template: "{}"},
|
||||
{name: "Unclosed", template: "{location_id", wantErr: name + " contains an unclosed template variable"},
|
||||
{name: "Unopened", template: "location_id}", wantErr: name + " contains an unopened template variable"},
|
||||
{name: "Empty", template: "{}", wantErr: name + " contains an empty template variable"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
|
||||
if err == nil {
|
||||
t.Fatal("RenderDistributorBundleID() error = nil, want error")
|
||||
_, err := renderDistributorTemplate(name, tt.template, func(variable string) (string, bool) {
|
||||
return "value", variable == "location_id"
|
||||
})
|
||||
if err == nil || err.Error() != tt.wantErr {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1668,9 +1881,13 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
||||
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
|
||||
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{}); err != nil {
|
||||
secrets, err := loadSecrets(SecretsConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if len(secrets) != 0 {
|
||||
t.Fatalf("staged secrets = %#v, want none", secrets)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
|
||||
t.Fatalf("environment value = %q, want original", got)
|
||||
}
|
||||
@@ -1706,9 +1923,13 @@ func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
|
||||
}
|
||||
t.Setenv("WEATHERREPORTER_SECRET", "existing")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
|
||||
if err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if err := applySecrets(secrets); err != nil {
|
||||
t.Fatalf("applySecrets() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||
t.Fatalf("environment value = %q, want from-file", got)
|
||||
}
|
||||
@@ -1735,9 +1956,13 @@ func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
|
||||
}
|
||||
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||
|
||||
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
|
||||
if err != nil {
|
||||
t.Fatalf("loadSecrets() error = %v", err)
|
||||
}
|
||||
if err := applySecrets(secrets); err != nil {
|
||||
t.Fatalf("applySecrets() error = %v", err)
|
||||
}
|
||||
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
|
||||
t.Fatalf("environment value = %q, want %q", got, tt.want)
|
||||
}
|
||||
@@ -1776,9 +2001,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
testutil.RequireSymlink(t, target, filepath.Join(dir, "SYMLINK"))
|
||||
},
|
||||
wantErr: "not a symlink",
|
||||
},
|
||||
@@ -1808,7 +2031,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tt.setup(t, dir)
|
||||
|
||||
err := loadSecrets(SecretsConfig{Directory: dir})
|
||||
_, err := loadSecrets(SecretsConfig{Directory: dir})
|
||||
if err == nil {
|
||||
t.Fatal("loadSecrets() error = nil, want error")
|
||||
}
|
||||
@@ -1823,7 +2046,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
|
||||
err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
|
||||
_, err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
|
||||
if err == nil {
|
||||
t.Fatal("loadSecrets() error = nil, want missing directory error")
|
||||
}
|
||||
@@ -1831,3 +2054,82 @@ func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
|
||||
t.Fatalf("error = %q, want read secrets directory context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||
t.Fatalf("create secrets directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(secretsDir, "A_SECRET"), []byte("new-value"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(secretsDir, "Z-INVALID"), []byte("unused"), 0o600); err != nil {
|
||||
t.Fatalf("write invalid secret: %v", err)
|
||||
}
|
||||
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\n")
|
||||
t.Setenv("A_SECRET", "original-value")
|
||||
|
||||
_, err := LoadFile(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid environment variable name") {
|
||||
t.Fatalf("LoadFile() error = %v, want invalid secret filename", err)
|
||||
}
|
||||
if got := os.Getenv("A_SECRET"); got != "original-value" {
|
||||
t.Fatalf("environment value = %q, want original-value after rejected load", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileValidationFailureLeavesSecretEnvironmentUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||
t.Fatalf("create secrets directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("new-value"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\nmissing_source:\n default: invalid\n")
|
||||
unsetEnvironment(t, "WEATHERREPORTER_SECRET")
|
||||
|
||||
_, err := LoadFile(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing_source.default") {
|
||||
t.Fatalf("LoadFile() error = %v, want configuration validation error", err)
|
||||
}
|
||||
if _, set := os.LookupEnv("WEATHERREPORTER_SECRET"); set {
|
||||
t.Fatal("WEATHERREPORTER_SECRET was set by a rejected configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySecretsRollsBackOnEnvironmentFailure(t *testing.T) {
|
||||
t.Setenv("A_SECRET", "original-value")
|
||||
unsetEnvironment(t, "Z_SECRET")
|
||||
|
||||
err := applySecrets([]secretValue{
|
||||
{name: "A_SECRET", value: "new-value"},
|
||||
{name: "Z_SECRET", value: "invalid\x00value"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `secret file "Z_SECRET"`) {
|
||||
t.Fatalf("applySecrets() error = %v, want Z_SECRET context", err)
|
||||
}
|
||||
if got := os.Getenv("A_SECRET"); got != "original-value" {
|
||||
t.Fatalf("A_SECRET = %q, want original-value after rollback", got)
|
||||
}
|
||||
if _, set := os.LookupEnv("Z_SECRET"); set {
|
||||
t.Fatal("Z_SECRET was set after failed environment application")
|
||||
}
|
||||
}
|
||||
|
||||
func unsetEnvironment(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
value, set := os.LookupEnv(name)
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatalf("unset environment variable %q: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if set {
|
||||
_ = os.Setenv(name, value)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,13 +40,17 @@ func Load(opts LoadOptions) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := loadSecrets(cfg.Secrets); err != nil {
|
||||
secrets, err := loadSecrets(cfg.Secrets)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := applySecrets(secrets); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -78,11 +78,18 @@ var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
|
||||
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
|
||||
|
||||
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
||||
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.bundle_id_template", template, newDistributorTemplateResolver(values, distributorTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(rendered) == "" {
|
||||
return "", fmt.Errorf("notify.distributor.bundle_id_template renders an empty bundle id")
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func RenderDistributorPipelineID(template string, values DistributorTemplateValues) (string, error) {
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, values, distributorPipelineTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, newDistributorTemplateResolver(values, distributorPipelineTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -93,11 +100,18 @@ func RenderDistributorPipelineID(template string, values DistributorTemplateValu
|
||||
}
|
||||
|
||||
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
|
||||
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.idempotency_key_template", template, newDistributorTemplateResolver(values, distributorIdempotencyTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(rendered) == "" {
|
||||
return "", fmt.Errorf("notify.distributor.idempotency_key_template renders an empty idempotency key")
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func RenderDistributorBatchBundleID(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", template, values, distributorBatchTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.batch.bundle_id_template", template, newDistributorBatchTemplateResolver(values, distributorBatchTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -108,7 +122,7 @@ func RenderDistributorBatchBundleID(template string, values DistributorBatchTemp
|
||||
}
|
||||
|
||||
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", template, values, distributorBatchPipelineTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.batch.pipeline_id_template", template, newDistributorBatchTemplateResolver(values, distributorBatchPipelineTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -119,7 +133,7 @@ func RenderDistributorBatchPipelineID(template string, values DistributorBatchTe
|
||||
}
|
||||
|
||||
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", template, values, distributorBatchIdempotencyTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate("notify.distributor.batch.idempotency_key_template", template, newDistributorBatchTemplateResolver(values, distributorBatchIdempotencyTemplateVariables))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -137,7 +151,7 @@ func RenderDistributorReportPaths(name string, templates []string, values Distri
|
||||
seen := make(map[string]struct{}, len(templates))
|
||||
for i, template := range templates {
|
||||
itemName := fmt.Sprintf("%s[%d]", name, i)
|
||||
rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
|
||||
rendered, err := renderDistributorTemplate(itemName, template, newDistributorTemplateResolver(values, distributorTemplateVariables))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,16 +168,18 @@ func RenderDistributorReportPaths(name string, templates []string, values Distri
|
||||
}
|
||||
|
||||
func validateDistributorTemplate(name, template string, allowed map[string]struct{}) error {
|
||||
_, err := renderDistributorTemplate(name, template, DistributorTemplateValues{}, allowed)
|
||||
_, err := renderDistributorTemplate(name, template, newDistributorTemplateResolver(DistributorTemplateValues{}, allowed))
|
||||
return err
|
||||
}
|
||||
|
||||
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
|
||||
_, err := renderDistributorBatchTemplate(name, template, DistributorBatchTemplateValues{}, allowed)
|
||||
_, err := renderDistributorTemplate(name, template, newDistributorBatchTemplateResolver(DistributorBatchTemplateValues{}, allowed))
|
||||
return err
|
||||
}
|
||||
|
||||
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
|
||||
type distributorTemplateResolver func(string) (string, bool)
|
||||
|
||||
func renderDistributorTemplate(name, template string, resolve distributorTemplateResolver) (string, error) {
|
||||
var rendered strings.Builder
|
||||
for i := 0; i < len(template); {
|
||||
switch template[i] {
|
||||
@@ -176,10 +192,11 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
|
||||
if variable == "" {
|
||||
return "", fmt.Errorf("%s contains an empty template variable", name)
|
||||
}
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
value, ok := resolve(variable)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||
}
|
||||
rendered.WriteString(distributorTemplateValue(variable, values))
|
||||
rendered.WriteString(value)
|
||||
i += end + 2
|
||||
case '}':
|
||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
||||
@@ -191,32 +208,22 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
|
||||
var rendered strings.Builder
|
||||
for i := 0; i < len(template); {
|
||||
switch template[i] {
|
||||
case '{':
|
||||
end := strings.IndexByte(template[i+1:], '}')
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("%s contains an unclosed template variable", name)
|
||||
}
|
||||
variable := template[i+1 : i+1+end]
|
||||
if variable == "" {
|
||||
return "", fmt.Errorf("%s contains an empty template variable", name)
|
||||
}
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||
}
|
||||
rendered.WriteString(distributorBatchTemplateValue(variable, values))
|
||||
i += end + 2
|
||||
case '}':
|
||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
||||
default:
|
||||
rendered.WriteByte(template[i])
|
||||
i++
|
||||
func newDistributorTemplateResolver(values DistributorTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
|
||||
return func(variable string) (string, bool) {
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", false
|
||||
}
|
||||
return distributorTemplateValue(variable, values), true
|
||||
}
|
||||
}
|
||||
|
||||
func newDistributorBatchTemplateResolver(values DistributorBatchTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
|
||||
return func(variable string) (string, bool) {
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", false
|
||||
}
|
||||
return distributorBatchTemplateValue(variable, values), true
|
||||
}
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
|
||||
|
||||
75
internal/config/overrides_external_test.go
Normal file
75
internal/config/overrides_external_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestConstructedReportOverridesUseExportedFields(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.Reports = map[string]config.ReportConfig{
|
||||
"daily": {
|
||||
DeterministicModules: []config.ModuleConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.AreaForecastDiscussion},
|
||||
},
|
||||
Distributor: config.ReportDistributorConfig{
|
||||
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
modules, err := cfg.ReportModuleOverrides()
|
||||
if err != nil {
|
||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||
}
|
||||
if got := modules[report.Daily]; len(got) != 2 || got[0].ID != module.Metadata || got[1].ID != module.AreaForecastDiscussion {
|
||||
t.Fatalf("module override = %#v, want exported daily modules", got)
|
||||
}
|
||||
paths, err := cfg.ReportDistributorPathOverrides()
|
||||
if err != nil {
|
||||
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
|
||||
}
|
||||
if got := paths[report.Daily]; len(got) != 1 || got[0] != "daily/{valid_start_date}/index.md" {
|
||||
t.Fatalf("path override = %#v, want exported daily path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructedExplicitEmptyReportOverridesAreRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
report config.ReportConfig
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "modules",
|
||||
report: config.ReportConfig{DeterministicModules: []config.ModuleConfigItem{}},
|
||||
wantErr: "reports.daily.deterministic_modules must contain at least one entry",
|
||||
},
|
||||
{
|
||||
name: "paths",
|
||||
report: config.ReportConfig{Distributor: config.ReportDistributorConfig{
|
||||
PathTemplates: []string{},
|
||||
}},
|
||||
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.Reports = map[string]config.ReportConfig{"daily": tt.report}
|
||||
err := config.Validate(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user