Prepare reports for Promptkit migration

This commit is contained in:
2026-07-31 03:53:47 +00:00
parent 2e0fb65a8b
commit d5fcbfd20c
87 changed files with 2223 additions and 3382 deletions

View File

@@ -21,9 +21,6 @@ weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N]
@@ -38,12 +35,11 @@ weatherreporter inspect sources [--config PATH] RUN_ID
| --- | --- |
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. |
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. |
| `generate tomorrow`, `three-day`, `weekend` | Use their report-defined valid period and accept the common generate flags. |
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--start`, `--end`, `--hours`, or `--duration`. |
| `generate storm` | Requires both `--start TIME` and `--end TIME`. Each time may be `YYYY-MM-DDTHH:MM` in the effective timezone or an RFC3339 timestamp with an explicit offset. |
| `generate tomorrow` | Uses the next local civil day and accepts the common generate flags. |
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--hours`, or `--duration`. |
| `run morning` and `run evening` | Run their defined report batches. `--out-dir` writes extra Markdown copies; `--out` is not accepted. |
`generate` accepts all seven report command names shown above. `run` accepts
`generate` accepts the four report command names shown above. `run` accepts
only `morning` and `evening`. Batch membership, workspace artifacts, and
notification sequencing are described in the [operations guide](operations.md).
@@ -84,8 +80,7 @@ time, valid period, and status:
```
When available, the summary also includes `reportPath`, `metadataPath`,
`dataPackagePath`, and `preflightPath`. Generated-text reports additionally
include `generatedTextRawPath`, `generatedTextResultPath`,
`dataPackagePath`, `preflightPath`, `generatedTextRawPath`, `generatedTextResultPath`,
`generatedTextPath`, and `renderContextPath`. `outputPath` is included only
when `--out` wrote an extra copy. Distributor notification, when attempted,
adds `notificationPath` and may add a compact `notification` object.
@@ -116,7 +111,6 @@ batch=morning total=2 succeeded=2 failed=0
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
| `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
| `--start TIME`, `--end TIME` | `generate storm` | Required storm-event bounds. |
| `--limit N` | `inspect reports` | Maximum runs to list. Defaults to `20`; `0` means no limit. |
Distributor notification is configured through `notify.distributor`; there are
@@ -128,7 +122,6 @@ no Distributor-specific CLI flags. See the [configuration reference](config.md).
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate today --date 2026-05-29 --out ./today.md
weatherreporter generate hourly --out ./hourly.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports
```

View File

@@ -102,10 +102,9 @@ Use `secrets.directory` when a file-backed secret is appropriate.
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
and `storm_id`. Pipeline and idempotency-key templates may also use
`bundle_id`. Dates use `YYYY-MM-DD`; times use `HHMM`; and stamps use
`YYYY-MM-DDTHHMM` in the effective report timezone. `storm_id` is
`{valid_start_stamp}-{valid_end_stamp}` for Storm Report and empty otherwise.
Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
`YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
effective report timezone.
Batch bundle and pipeline templates accept `location_id`, `batch`,
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
@@ -124,9 +123,6 @@ The default paths are:
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md` |
| `today` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md` |
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md` |
| `three_day` | `three-day/{valid_start_date}/{run_id}.md`, `three-day/{valid_start_date}/index.md` |
| `weekend` | `weekend/{valid_start_date}/{run_id}.md`, `weekend/{valid_start_date}/index.md` |
| `storm` | `storm/{storm_id}/{run_id}.md`, `storm/{storm_id}/index.md` |
See the [operations guide](operations.md) for notification timing, uploaded
artifact selection, and failure handling.
@@ -189,9 +185,8 @@ prior comparable module snapshot.
`reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`, `three_day`,
`weekend`, and `storm`. Configuration also accepts `three_day_outlook`,
`weekend_outlook`, and `storm_report`; hyphens and underscores are equivalent.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
and underscores are equivalent.
Each report entry can contain:

View File

@@ -30,11 +30,10 @@ scriptorium run [--config <path>] [--profile <profile>] \
[<extra_arg> ...]
```
`render` is the preflight command. `run` writes either a Markdown report or a
raw generated-text artifact to the supplied `--out` path. The structured
generated-text use of `run` has the same argv as Markdown generation; it does
not add `--format`, `--schema`, `--schema-path`, or `--json-schema` flags.
Prompt configuration selected by `<prompt_id>` controls that output.
`render` is the preflight command. `run` writes a structured generated-text
artifact to the supplied `--out` path. It does not add `--format`, `--schema`,
`--schema-path`, or `--json-schema` flags. Prompt configuration selected by
`<prompt_id>` controls that output.
## Inputs and Outputs

View File

@@ -12,8 +12,8 @@ inspection requests. Generation and batch requests may supply collector,
renderer, store, and notifier implementations for tests; production defaults
use the focused packages.
A report result contains the module snapshot, prompt package, available
Scriptorium results, generated-text artifacts when used, report and metadata
A report result contains the module snapshot, prompt package, Scriptorium
results, generated-text artifacts, report and metadata
paths, prior snapshot, Recent Changes, and notification information. A batch
result contains aggregate counts, per-report outcomes, and an optional batch
notification. Inspection returns persisted values only.
@@ -39,17 +39,17 @@ ordered work:
snapshot and calculate Recent Changes.
3. Build and save the prompt data package, run Scriptorium render preflight,
save any preflight result, and save initial metadata.
4. Produce managed Markdown according to the report generation mode.
4. Run structured Scriptorium generation, validate and save generated text,
build and save a render context, and render the managed Markdown template.
5. Optionally make an output copy, save final metadata, optionally notify
Distributor from the managed report path, and save metadata again when a
notification path is produced.
Direct-Markdown reports prepare the managed report and invoke the Scriptorium
run boundary. Generated-text-template reports look up their catalog definition,
run structured Scriptorium output to the raw artifact, preserve any structured
run result, validate and save generated text, build and save a render context,
then render the embedded Markdown template. Schema, template, and subprocess
details remain in their [generated-text](generatedtext.md),
Every report looks up its catalog definition, runs structured Scriptorium
output to the raw artifact, preserves the structured run result, validates and
saves generated text, builds and saves a render context, then renders the
embedded Markdown template. Schema, template, and subprocess details remain in
their [generated-text](generatedtext.md),
[report-template](reporttemplate.md), and [Scriptorium adapter](scriptorium-adapter.md)
owners.
@@ -104,7 +104,7 @@ Focused tests are in `internal/app/app_test.go` and
- A report uses one explicit normalized collection throughout its generation.
- Render preflight precedes report generation.
- Recent Changes compare structured module snapshots.
- Generated-text reports render from a validated typed context, never directly
from a raw prompt package.
- Reports render from a validated typed context, never directly from a raw
prompt package.
- Only managed Markdown reports are notification sources; output copies are
never uploaded.

View File

@@ -25,19 +25,14 @@ requires a change between its low, possible, likely, and high categories.
| Comparator | Required snapshot data | Compared values |
| --- | --- | --- |
| `CompareDaily` | `derived_daily_summary`, `derived_daypart_summaries` | Low and high temperature, daily precipitation probability and timing, peak gust, alerts, and aggregate indicators |
| `CompareThreeDay` | `derived_daypart_summaries` | Per-day temperatures, precipitation probability and timing, peak gust, indicators, and added or removed outlook days |
| `CompareWeekend` | `derived_daypart_summaries` | The three-day values with weekend-prefixed change types |
For daily comparison, `alert_digest` and `precip_timing` are optional: alerts
are compared when present, and timing is compared only when both snapshots
contain it. The multi-day comparators build their day map from daypart
summaries. A missing or added day becomes a dedicated change rather than a
comparison against invented data.
contain it.
The application selects a comparator only after state lookup establishes a
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator;
Three-day and Weekend use their named comparators. Other report types, such as
Storm, produce no Recent Changes list.
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator.
Hourly reports do not produce a Recent Changes list.
## Missing data and failures
@@ -51,8 +46,8 @@ behavior. It does not decide report compatibility or retain snapshots.
## Verification and invariants
Focused tests cover the daily, three-day, and weekend strategies, threshold
boundaries, indicator and alert changes, and missing required stanzas:
Focused tests cover the daily strategy, threshold boundaries, indicator and
alert changes, and missing required stanzas:
```sh
go test ./internal/changes

View File

@@ -33,10 +33,8 @@ Report identity controls the summary shape:
| --- | --- |
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
| Three-day, Weekend | One clipped daily summary for each overlapping local day |
| Storm | One summary for the explicit report window |
`DaypartSummaries` is collected from the resulting daily or storm summaries.
`DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md).
@@ -55,8 +53,7 @@ not access the CLI, filesystem, subprocesses, or network.
## Verification and invariants
Focused tests cover collected-fact separation, report-period selection,
hourly and storm behavior, daily and partial-day summaries, and convective
outlook selection:
hourly behavior, daily summaries, and convective outlook selection:
```sh
go test ./internal/facts

View File

@@ -8,12 +8,12 @@ maintainer-facing context fields belong to [report templates](../templates.md).
## Catalog and validation
Only the Daily, Today, Tomorrow, and Hourly report definitions use the
generated-text-template mode. `LookupDefinition` rejects a direct-Markdown
definition, 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 schema, builds a render context,
and renders through `internal/reporttemplate`.
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated text. `LookupDefinition` rejects unknown schema or template IDs and
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
value and canonical normalized JSON, loads its canonical schema through
`internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`.
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
@@ -47,5 +47,5 @@ go test ./internal/generatedtext
```
Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. Every generated-text definition must resolve to exactly
one supported catalog pair.
module and fact values. Every report definition must resolve to exactly one
supported catalog pair.

View File

@@ -39,8 +39,6 @@ The registry declares these ordered default compositions:
| Today | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, hourly forecast, today planning |
| Tomorrow | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, tomorrow planning, hourly forecast |
| Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
| Three-day and Weekend | metadata, current conditions, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows |
| Storm | metadata, current conditions, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story |
The only non-empty default option is the AFD section selection. It accepts a
`sections` list; omitted or empty selects all available sections. Report

View File

@@ -1,7 +1,7 @@
# Report Registry Internals
`internal/report` owns the registry of report identities and the data declared
for each one: resolution, generation mode, prompt identity, comparison policy,
for each one: resolution, prompt identity and version, comparison policy,
artifact group, output-copy name, default module composition, and Distributor
path declarations. The public command syntax is owned by the
[CLI reference](../cli.md); configuration aliases and overrides are owned by
@@ -10,34 +10,28 @@ the [configuration reference](../config.md).
## Definitions and resolution
Each `Definition` declares a stable ID and display name, prompt ID, generation
mode, optional template and generated-text schema IDs, valid-period resolver,
version, template and generated-text schema IDs, valid-period resolver,
comparison strategy, artifact group, batch-copy filename, Distributor path
templates, generation eligibility, compatible prior IDs, default modules, and
batch eligibility flags. `Resolved` combines that definition with the valid
period and run metadata for one invocation.
| Report ID | Mode | Period policy | Comparison | Registry batch flag | Output copy |
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
| --- | --- | --- | --- | --- | --- |
| `daily` | Generated text + template | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
| `today` | Generated text + template | Selected or current local civil day | Same valid date | Morning | `today.md` |
| `tomorrow` | Generated text + template | Next local civil day | Same valid date | Evening | `tomorrow.md` |
| `hourly` | Generated text + template | Rolling six-hour interval | Rolling window | — | `hourly.md` |
| `three_day` | Scriptorium Markdown | Generation time through the third following local midnight | Same valid date | Morning | `three-day.md` |
| `weekend` | Scriptorium Markdown | Upcoming weekend window | Weekend window | Morning | `weekend.md` |
| `storm` | Scriptorium Markdown | Caller-supplied event window | Explicit window | — | `storm.md` |
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` |
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `hourly.md` |
The four generated-text reports pair their report ID with matching template and
schema IDs. The three direct-Markdown reports leave both IDs empty. Exact
template fields and schema assets belong to [report templates](../templates.md)
Each report pairs its ID and prompt version with matching template and schema
IDs. Exact template fields and schema assets belong to [report templates](../templates.md)
and [generated-text internals](generatedtext.md).
All valid periods are half-open. Storm accepts local `YYYY-MM-DDTHH:MM` values
in the effective report timezone or offset-bearing RFC3339 values; its end
must follow its start. Resolving Weekend directly on Sunday is rejected.
All valid periods are half-open.
## Registry collaborators
`DefaultRegistry` is the only source of the seven report definitions.
`DefaultRegistry` is the only source of the four report definitions.
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
report identity rules. Registry overrides clone a definition and replace its
module list only after the report ID is recognized.
@@ -54,13 +48,12 @@ membership and produces the actual batch plan.
Each definition supplies an ordered `[]module.ConfigItem`; the complete
report-to-module mapping is maintained in [module internals](module.md).
`ArtifactGroup`, `BatchOutputName`, `Generated`, and comparison compatibility
`ArtifactGroup`, `BatchOutputName`, and comparison compatibility
are likewise consumed by state and orchestration rather than recomputed there.
Unknown report IDs or batch names, an invalid weekend resolution, and invalid
storm windows return errors. The registry never collects weather data, builds
modules, parses CLI flags, writes state, executes Scriptorium, or delivers a
report.
Unknown report IDs or batch names return errors. The registry never collects
weather data, builds modules, parses CLI flags, writes state, executes
Scriptorium, or delivers a report.
## Verification and invariants

View File

@@ -40,7 +40,7 @@ single-report artifact identifiers.
## Metadata and durable writes
`Metadata` is the durable inventory for a run. It records its schema version,
run identity, generated and valid timestamps, artifact group and mode, source
run identity, generated and valid timestamps, artifact group, source
content and provenance, and the module snapshot, data-package, preflight,
report, generated-artifact, and notification locations when present.
@@ -64,7 +64,6 @@ only considers an earlier compatible report in the same artifact group and
supports the comparison strategies defined by the report request:
- `same_valid_date` finds an earlier generated report for the same valid day.
- `weekend_window` finds a prior comparable weekend window.
The newest eligible metadata record wins; the current run is excluded.
Unreadable or malformed candidate metadata is ignored so a damaged historical

View File

@@ -16,10 +16,9 @@ weatherreporter generate today --out ./today.md
A generation collects weather data, resolves the report period, builds and
persists the module snapshot and prompt data package, runs Scriptorium
preflight, then produces the managed Markdown report. Daily, Today, Tomorrow,
and Hourly reports additionally persist generated-text artifacts, validate the
structured generated text, and render Markdown from the validated text and
deterministic values.
preflight, then persists generated-text artifacts, validates the structured
generated text, and renders the managed Markdown report from the validated text
and deterministic values.
The managed report and its final metadata are saved before single-report
Distributor notification is attempted. `--out` writes an extra operator copy;
@@ -68,11 +67,11 @@ workspace/
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
```
The generated-text and render-context artifacts are written only by Daily,
Today, Tomorrow, and Hourly reports. A report's metadata links the module
snapshot, data package, preflight artifact, managed report, and any available
generated-text or single-report notification artifact. Batch notification
artifacts are separate batch-level records under `notifications/batches`.
The generated-text and render-context artifacts are written for every report.
A report's metadata links the module snapshot, data package, preflight artifact,
managed report, generated-text artifacts, and any available single-report
notification artifact. Batch notification artifacts are separate batch-level
records under `notifications/batches`.
RunIDs begin with the UTC generation timestamp and report ID. A Daily RunID
also contains its local valid date so multiple Daily reports in one batch have

View File

@@ -130,12 +130,8 @@ lookup. Keep weather derivation and path construction out of templates.
weather facts.
- LLM prompts receive curated module-based packages rather than raw,
unbounded source payloads.
- For generated-text-template reports, generated text is limited to defined
prose slots, validated before use, and rendered through typed or otherwise
explicit contexts.
- Direct-Markdown reports receive the same curated prompt-package boundary but
produce managed Markdown directly through Scriptorium rather than the
generated-text schema and repository-template workflow.
- Generated text is limited to defined prose slots, validated before use, and
rendered through typed or otherwise explicit contexts.
- Repository-owned templates arrange validated prose and deterministic facts;
they do not perform meteorological derivation.

View File

@@ -7,8 +7,7 @@ status; current behavior is documented outside `docs/roadmap/`.
Status: Proposed and unimplemented.
Manual Storm Report generation is implemented; see the [CLI reference](../cli.md).
Automatic storm-event evaluation remains unimplemented.
Storm reporting, whether manual or automatic, is unimplemented.
Possible direction:
@@ -16,7 +15,7 @@ Possible direction:
story context, hourly thresholds, and material forecast changes.
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
3. Persist storm lifecycle state.
4. Generate or update Storm Reports only when a meaningful event is present.
4. Generate or update a storm report only when a meaningful event is present.
5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states:
@@ -29,8 +28,8 @@ Possible lifecycle states:
- `resolved`
Before implementation, the design must preserve scheduled report behavior,
manual Storm Report generation, inspectable evaluator failures, and fixture
coverage for deterministic candidate detection.
inspectable evaluator failures, and fixture coverage for deterministic
candidate detection.
## Future Report Types
@@ -59,7 +58,7 @@ Possible future modules:
Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
- `storm_window_summary` if manual or automatic storm reports need a dedicated
prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove

View File

@@ -0,0 +1,944 @@
# Promptkit Migration Implementation Plan
Status: Decision-complete implementation plan; unimplemented.
## Purpose And Authority
This document defines the ordered implementation procedure for the
[Promptkit migration roadmap](promptkit.md). The feature roadmap is
authoritative for scope, user intent, policy choices, and the desired end
state. This plan is authoritative for sequencing, concrete package ownership,
compatibility work, tests, documentation updates, and completion gates.
Implement the stages in order. Do not reinterpret a roadmap decision merely
because the current Scriptorium implementation makes another path shorter.
When implementation reveals a conflict with the roadmap, stop and update the
roadmap and this plan deliberately rather than introducing an implicit policy
change.
This plan follows the repository's
[architecture](../policy/architecture.md),
[documentation](../policy/documentation.md), and
[testing](../policy/testing.md) policies. All stages are parts of one
migration change. Intermediate stages may temporarily retain code needed by a
later cutover, but no intermediate state should be released or documented as
the final current behavior.
## Cross-Stage Rules
- Pin `gitea.maximumdirect.net/eric/promptkit` at exactly `v0.4.0`. Do not
commit a `go.work`, local `replace`, pseudo-version, or unpublished commit.
- Keep Promptkit types inside `internal/adapters/promptkit`, that package's
tests, and the external contract test that verifies `internal/promptassets`
with the real Promptkit inspector. App, CLI, report, state, and domain
packages use project-owned types.
- Keep Promptkit's opaque prepared-execution handle inside its adapter. Do not
expose it through an app interface, serialize it, or make it restartable.
- Construct one Promptkit engine per `generate` or `run` CLI invocation.
Every report in a batch shares that engine. Inspection commands that only
read persisted state do not construct an engine.
- Keep batches sequential. Do not add retries, output repair, direct Markdown
generation, arbitrary backend registration, or live-provider tests.
- Preserve the exact persisted `data_package` bytes as the only model input.
Promptkit receives those bytes through an inline artifact with the managed
YAML path as provenance; it never receives an unrestricted file reference.
- Persist successful preparation before `RunPrepared`. If preparation-record
persistence or enabled debug persistence fails, discard the handle and do
not call the provider.
- Keep normal artifacts, errors, logs, and CLI summaries free of credentials,
rendered messages, schema bodies, data-package bodies, provider response
bodies, and full effective parameter maps.
- Use atomic writes for durable state and debug files. Preserve every
non-secret artifact reached before a later failure when practical.
- Use deterministic Promptkit model-client fakes at the adapter boundary and
project-owned executor fakes at the app boundary. The default test suite
remains offline and credential-free.
- At each stage, update or delete existing tests according to the testing
policy. Protect contracts and failure behavior; do not preserve tests that
exist only to assert the retired subprocess implementation.
- Run the focused checks named by the stage while iterating. Before completing
every stage, run `git diff --check`. Run the full repository gate in the
final stage.
## Fixed Package And Contract Decisions
Use these ownership boundaries so later stages do not need to choose an
architecture while editing:
| Area | Owner | Decision |
| --- | --- | --- |
| Embedded prompt definitions, referenced prompt content, and provider-facing schemas | `internal/promptassets` | Embed one centralized asset tree and expose read-only prompt and schema `fs.FS` sources plus schema lookup by report schema ID. This package does not import Promptkit. |
| Project-owned prompt execution contract | `internal/promptexec` | Own prompt inspection, profile inspection, preparation, execution, validation, usage, debug, and neutral error values. This package does not import Promptkit, app, CLI, report, or state. |
| Promptkit integration | `internal/adapters/promptkit` | Construct and own the Promptkit engine, translate project requests and results, classify public Promptkit errors, and enforce prepared-handle lifecycle. |
| Workflow ordering | `internal/app` | Resolve reports, inspect prompt/profile selections, collect weather, build and save inputs, record preparation, invoke execution, persist results, perform final generated-text validation, render Markdown, and notify. |
| Durable artifacts | `internal/state` | Own v2 paths, preparation and execution schemas, metadata compatibility, atomic writes, and inspection reads. |
| Engine composition and flags | `internal/cli` | Construct one executor per action invocation, pass it into app requests, parse `--llm-debug-dir`, and expose project-owned summary paths. |
`internal/promptexec.Executor` must provide three operations:
1. inspect an exact prompt ID and version;
2. inspect one explicit profile ID; and
3. prepare and execute one request.
The execution operation accepts a preparation callback. The adapter calls that
callback exactly once after successful `PrepareExecution` and before
`RunPrepared`. The callback receives a safe project-owned preparation value
and, only when requested, a project-owned sensitive debug value. A callback
error aborts execution. A preparation failure returns a classified
project-owned attempt error without calling the callback; app orchestration
uses that error to persist a failure receipt.
The adapter execution request contains the exact prompt ID and version, the
optional configured profile override, the exact YAML bytes, the managed
data-package path used only as inline provenance, and whether sensitive debug
capture is enabled. It does not contain a provider output path.
Project-owned errors use stable categories for invalid configuration, invalid
request, prompt not found/load, profile not found/load, missing credential,
artifact load, prompt render, capacity, generation, operational validation,
completed validation rejection, cancellation, and deadline. A capacity error
also carries the non-secret backend ID. Its `Error` text is
Weatherreporter-owned and safe for CLI output; an unexported or non-serialized
cause may remain available to `errors.Is` and `errors.As`. Never persist
arbitrary dependency error prose.
## Stage 1: Correct The Implemented Report Surface
### Goal
Remove the unfinished three-day, weekend, and storm products before changing
the LLM integration. The repository should expose only Daily, Today, Tomorrow,
and Hourly while continuing to use Scriptorium temporarily for those four
reports.
### Work
1. Remove the three report definitions and their files under `internal/report`.
Remove their IDs, command names, aliases, period inputs, generation modes,
batch flags, registry order, and direct-Markdown-only declarations.
2. Add `PromptVersion string` to `report.Definition` and set it to `1.0.0` on
all four retained definitions. Since every retained report uses generated
text plus a repository template, remove the now-redundant
`GenerationMode` and `Generated` fields and their branches.
3. Remove three-day, weekend, and storm command parsing, help text, app request
fields/constants, resolution branches, Distributor path variables, and
configuration report aliases.
4. Remove report-specific code whose only caller was one of the retired
products. Use graph traces before deletion. This includes the dedicated
three-day/weekend Recent Changes comparators, storm-window fact derivation,
multi-day report derivation used only by these definitions, storm time
parsing, and their focused tests when no retained behavior depends on them.
Do not remove general meteorological behavior merely because a fixture or
prose string contains words such as “storm” or “weekend.”
5. Keep morning and evening planning behavior exactly as implemented:
morning plans Today and Tomorrow, evening plans Tomorrow, and both may add
eligible future Daily reports from complete hourly coverage.
6. Update the current CLI, configuration, report-registry, facts, modules,
changes, operations, troubleshooting, and other affected documentation to
describe only the four implemented reports. Update `docs/roadmap/future.md`
so it no longer claims that a manual Storm Report exists; future versions
of these products remain explicitly unimplemented roadmap work.
### Tests
- Rewrite report registry and CLI parser tests around exactly four commands.
- Remove fake successful-generation tests for nonexistent prompts.
- Preserve Daily/Today/Tomorrow/Hourly period, module, batch-planning,
Distributor path, and output-name coverage.
- Add negative CLI/config tests showing the retired command names and aliases
are rejected.
- Run:
```sh
go test ./internal/report ./internal/config ./internal/facts \
./internal/changes ./internal/app ./internal/cli
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
### Exit Gate
No implemented registry, CLI, configuration, app, current-state document, or
test claims that three-day, weekend, or storm generation exists. The four
retained reports still pass through the existing generated-text/template
workflow, and each carries exact prompt version `1.0.0`.
## Stage 2: Promote And Reconcile Runtime Prompt Assets
### Goal
Create the single embedded prompt/schema corpus used by Promptkit and by
Weatherreporter's final generated-text boundary.
### Work
1. Add Promptkit `v0.4.0` to `go.mod` and `go.sum`.
2. Create `internal/promptassets` with an embedded tree rooted at:
```text
assets/
prompts/
common/
daily/
today/
tomorrow/
hourly/
schemas/
```
Expose copied/read-only prompt and schema filesystem views and a
`Schema(id)` lookup for the four report schema IDs. Keep path constants
private except where a stable project contract is necessary.
3. Promote only the four `*_generated_text` prompt definitions from
`docs/roadmap/scriptorium`. Do not promote `weather.daily_report`.
4. Use the Scriptorium corpus's common system, data-package guidance, and
report-specific files as the behavioral base. Reconcile rather than
editorially rewrite:
- preserve its source weighting, hazard, precipitation, and style rules;
- add the optional `confidence` field instruction to Daily, Today, and
Hourly so all prompt text agrees with the canonical domain types and
schemas;
- retain Tomorrow's existing confidence instruction;
- prefer the corpus's correct Tomorrow daily framing over the stale
hourly-style file currently under `internal/reporttemplate/prompts`;
- make no unrelated prompt-tone or product-policy changes.
5. Every prompt definition must use:
- its existing prompt ID;
- version `1.0.0`;
- `default_profile: gemini-flash-latest`;
- one required `data_package` input with
`content_type: application/yaml`;
- JSON output with `validation_mode: json_schema`;
- a path inside the embedded schema root; and
- zero or omitted `repair_attempts`.
6. Move the four canonical schemas from `internal/reporttemplate/schemas` into
`internal/promptassets/assets/schemas`. Preserve their current application
contracts, including optional `confidence`, required summary/discussion,
array discussion for day-style reports, string discussion for Hourly,
`additionalProperties: false`, and the corrected Daily `$id` and title.
7. Change `internal/generatedtext` to load these canonical schemas from
`promptassets`. Remove schema and prompt-fragment ownership from
`internal/reporttemplate`; it should embed and render Markdown templates and
partials only.
8. Retain `docs/roadmap/scriptorium` as migration source evidence until the
final cleanup stage.
### Tests
- Add table-driven asset tests for exactly four prompt IDs and versions,
referenced-file resolution, default profiles, YAML input declarations,
output contracts, and schema lookup.
- Construct a Promptkit engine over the embedded sources in an external test
package and call `InspectPrompt` for every report. Do not use a provider or
credentials.
- Retain generated-text schema and typed-validation tests, now reading the
relocated canonical schemas.
- Assert that no active embedded prompt uses `local-heavy`,
`pipeline-weather/`, JSON input metadata, positive repair attempts, or
`weather.daily_report`.
- Run:
```sh
go test ./internal/promptassets ./internal/generatedtext \
./internal/reporttemplate
git diff --check
```
### Exit Gate
One embedded source contains exactly four valid Promptkit prompt definitions
and four canonical schemas. Prompt inspection succeeds offline for every
report, and no duplicate runtime provider-facing schema remains.
## Stage 3: Define The Neutral Prompt Execution Contract
### Goal
Define the complete project-owned boundary between Weatherreporter and an LLM
execution adapter. This stage introduces no Promptkit imports and makes no
production orchestration changes.
### Work
1. Create `internal/promptexec` with the neutral contract fixed above. Include:
- exact prompt and profile inspection values;
- safe preparation provenance;
- execution result, validation, token usage, and timing;
- optional sensitive preparation/execution debug values;
- stable error categories and a typed capacity error; and
- small helpers for safe diagnostic bounding and copying.
2. The safe preparation value includes prompt ID/version/hash, rendered prompt
hash, input hashes, selected profile/backend, effective model name, output
format/validation/schema path, preparation start/end/duration, and the
managed data-package path. It excludes endpoints, API-key environment
names, full parameters, rendered messages, schema bodies, and input bodies.
3. The safe execution value includes Promptkit run ID, repeated prompt and
input provenance, selected profile/backend/model, generated-content hash,
usage, execution start/end/duration, completed validation status, bounded
validation diagnostics, and raw output bytes. It excludes endpoint and full
effective parameters.
4. Bound routine validation diagnostics to at most 10 entries and at most
1,024 bytes per entry, truncating on a valid UTF-8 boundary. Bound a
persisted safe error message to 2,048 bytes. Prefer stable categories and
fields over diagnostic prose.
5. Specify the execution lifecycle in interface comments and tests:
- inspection is side-effect-free;
- preparation invokes the callback exactly once only after successful
preparation;
- callback failure prevents provider execution;
- completed validation rejection is a result, not an operational error;
- operational failure does not fabricate a completed result; and
- sensitive debug values are populated only when explicitly requested.
6. Keep copying and truncation helpers internal to `promptexec`. Callers must
not be able to mutate byte slices, maps, or diagnostics retained inside
contract values.
### Tests
- Add compile-time test fakes that implement the complete interface without
importing Promptkit.
- Add table-driven tests for every error category, capacity fields, diagnostic
count and byte limits, UTF-8-safe truncation, error-message bounds, and
defensive copying.
- Assert through representative fixtures that safe contract values contain no
endpoint, credential, rendered message, schema body, input body, response
body, or full parameter map.
- Run:
```sh
go test ./internal/promptexec
git diff --check
```
### Exit Gate
`internal/promptexec` provides a stable, documented, offline-tested contract
that can represent every inspection, preparation, execution, validation,
usage, debug, and failure outcome required by the roadmap. It has no Promptkit,
app, CLI, report, or state dependency.
## Stage 4: Implement The Promptkit Adapter
### Goal
Implement and thoroughly test Promptkit behind the Stage 3 contract without
modifying application or CLI orchestration.
### Work
1. Create `internal/adapters/promptkit`. Its constructor:
- uses `promptassets` through `WithPromptFS` and `WithSchemaFS`;
- selects one external profile directory through Promptkit config or one
profile file through `WithProfileFile`;
- optionally registers
`promptkit.LocalBackend(endpoint, concurrencyLimit)`;
- sets the transport timeout;
- accepts an injected Promptkit `LLMClient` only through an adapter-local
test constructor or option; and
- returns project-owned configuration errors.
2. Implement prompt and profile inspection with exact versions and explicit
field-by-field translation into `promptexec` values. Do not return or embed
Promptkit values.
3. Implement execution:
- call `PrepareExecution` with the exact requested version and optional
profile override;
- pass the exact YAML as
`InlineWithURI(dataPackagePath, string(bytes))`;
- immediately `defer handle.Discard()` after a successful prepare;
- map safe preparation details and invoke the preparation callback;
- call `RunPrepared` only after the callback succeeds;
- treat `ValidationFailed` as a completed result rather than an operational
error; and
- return no invented execution result for operational errors.
4. Translate Promptkit's public error sentinels with `errors.Is`, and
`CapacityError` with `errors.As`. Preserve caller cancellation and deadline
identities. Do not parse error strings.
5. Capture sensitive debug values only when requested. Use project-owned debug
structs and explicit field mapping rather than serializing whole Promptkit
values, so a future upstream field cannot silently enter an artifact.
6. Keep the prepared handle entirely within the adapter call. Discard it after
every success or failure path and never make it serializable or reusable.
### Tests
- Use an injected deterministic Promptkit model client. Never use a live
endpoint or credential.
- Cover prompt/profile inspection, profile override precedence, built-in and
external profiles, endpoint-only profiles, the conventional local backend,
local concurrency/capacity rejection, missing credentials, and constructor
validation.
- Cover preparation callback ordering, callback failure preventing generation,
exact inline bytes and URI, exact prompt version, successful execution,
completed schema rejection with raw output, operational generation and
validation failures with no partial result, cancellation, timeout, and
handle discard.
- Assert that safe values and errors do not contain rendered messages,
endpoints, schema bodies, input bodies, response bodies, credentials, or
full parameter maps.
- Run:
```sh
go test ./internal/promptexec ./internal/adapters/promptkit
go test -race ./internal/adapters/promptkit
git diff --check
```
### Exit Gate
The Promptkit adapter is a complete offline-tested implementation of the
neutral contract. No Promptkit type is referenced outside the adapter, its
tests, or the external asset contract test, and no production path uses the
adapter yet.
## Stage 5: Introduce Durable State V2
### Goal
Add the final normal-artifact model and backward-compatible metadata reading
before app orchestration starts writing the new records. Sensitive debug
storage remains out of scope until Stage 6.
### Work
1. Add the new-run path members `Preparation` and `Execution`:
- preparation:
`preflight/<group>/<date>/prompt_preparation.<runID>.json`;
- execution:
`snapshots/<group>/<date>/prompt_execution.<runID>.json`.
Keep `workspace.preflight_dir` and its default physical directory unchanged.
Retain `Preflight` and `GeneratedTextResult` only as temporary legacy
write-path members required by the uncut Scriptorium workflows; mark them
for deletion in Stage 10. New Promptkit code must use only the new members.
2. Add `PromptPreparationArtifact` with schema
`weatherreporter.prompt_preparation.v1`. It represents either successful
preparation or a failed preparation receipt and includes schema version,
status, report/RunID, prompt identity, safe preparation provenance when
available, timing, data-package path, and a bounded classified error when
failed.
3. Add `PromptExecutionArtifact` with schema
`weatherreporter.prompt_execution.v1`. It represents success, completed
validation rejection, or operational failure and includes safe execution
provenance, validation, usage, timing, reached artifact paths, and a bounded
classified error when failed. It never embeds generated content.
4. Advance newly written metadata to `weatherreporter.metadata.v2`. Replace
`preflightPath` and `generatedTextResultPath` with `preparationPath` and
`executionPath`. Populate paths only after the corresponding artifact has
actually been saved.
5. Implement explicit v1/v2 metadata decoding:
- accept only the known v1 and v2 schema versions;
- normalize v1 `preflightPath` and `generatedTextResultPath` internally for
inspection;
- preserve v1 field names when a loaded v1 record is marshaled by
`inspect metadata`;
- write only v2 through the new Promptkit state APIs; the temporary
Scriptorium path may continue writing v1 until its Stage 10 removal; and
- never dual-write legacy aliases.
Direct inspection of v1 metadata, modules, data packages, sources, and
referenced artifacts remains available for any historical report ID.
Prior-snapshot reconstruction is required only for the four retained report
IDs; do not restore retired definitions solely for legacy comparison.
6. Add typed `SavePromptPreparation`, `SavePromptExecution`, and corresponding
typed load methods where inspection needs them. Temporarily retain the
Scriptorium write methods so the current production paths compile through
Stage 9; delete those methods during Stage 10. Keep long-term legacy support
read-only.
7. Update app/CLI result structs and JSON field names to
`preparationPath`, `executionPath`, and optional `llmDebugPath`; temporarily
adapt old orchestration so the tree compiles until the cutover stages.
Do not write v2 records through the Scriptorium path.
### Tests
- Add exact path, schema-defaulting, round-trip, required-field, atomic-write,
and unknown-version tests.
- Add v1 fixtures covering both generated-text and legacy preflight references;
verify list, metadata, modules, data-package, and source inspection.
- Verify that re-marshaled v1 inspection uses v1 field names and that v2 output
contains no deprecated aliases.
- Run:
```sh
go test ./internal/state ./internal/app ./internal/cli
git diff --check
```
### Exit Gate
State can read historical v1 runs and write the complete normal v2 artifact
contract through the new APIs. The existing production generation path still
compiles and remains unchanged until cutover; no new Promptkit artifact uses a
Scriptorium-specific filename, schema, or JSON field.
## Stage 6: Add Secure LLM Debug Persistence
### Goal
Implement the explicitly enabled sensitive-debug store as a small, independently
auditable boundary before any CLI command can request debug capture.
### Work
1. Add a focused debug writer, owned by state or a narrow state-adjacent
package, for an explicit operator root outside normal artifact derivation.
It must:
- validate or create the root before collection or provider work;
- use `<root>/<report-id>/<valid-date>/<run-id>/`;
- write `preparation.json` and `execution.json` atomically;
- create directories with `0700` and files with `0600`;
- reject symlinks, path escape, non-directory roots, and unsafe
run/report/date segments; and
- return the per-run debug directory as the project-owned summary path.
2. Define explicit project-owned debug wire structs. Preparation debug may
contain rendered messages, structured-output schema, and effective
endpoint/parameters. Execution debug may additionally contain raw generated
output and completed validation details.
3. Exclude direct credentials, resolved environment credential values, and
arbitrary dependency objects. Use field-by-field mapping so future
`promptexec` or Promptkit fields are not serialized implicitly.
4. Make disabled debug behavior a no-op that performs no filesystem access.
A requested debug initialization or write error is returned to the caller;
it is never silently downgraded.
5. Keep this writer independent of normal state path derivation. Normal state
inspection must not discover or serve sensitive debug artifacts.
### Tests
- Verify exact grouping, atomic replacement, disabled behavior, and returned
per-run paths.
- Verify directory `0700` and file `0600` permissions on supported platforms.
- Cover traversal, absolute-segment, symlink-root, symlink-component,
non-directory, and invalid report/date/run segment rejection.
- Marshal representative debug fixtures and verify credentials and resolved
secret values are absent while the explicitly allowed diagnostic fields are
retained.
- Run:
```sh
go test ./internal/state
git diff --check
```
### Exit Gate
Sensitive debug persistence is secure, explicitly rooted, atomic, isolated
from normal state, and fully tested. No production CLI path enables it yet.
## Stage 7: Add Promptkit Configuration, Composition, And Inspection
### Goal
Add the configuration and project-owned composition seams needed for cutover,
and centralize pre-collection prompt/profile validation. Retain Scriptorium as
the production generator until Stage 8 so this stage does not create a
half-cut-over runtime.
### Work
1. Add `config.PromptkitConfig` and nested local config with exactly:
- `profile`;
- `profile_file`;
- `profile_dir`;
- `timeout`, default `2m`;
- `local.endpoint`; and
- `local.concurrency_limit`, default `1`.
Scriptorium config remains temporarily because production generation has
not yet cut over. Promptkit validation rejects simultaneous profile
sources, non-positive transport timeout, an invalid nonblank local
endpoint, and negative concurrency. A blank local endpoint leaves `local`
unregistered; concurrency zero means unlimited.
2. Add an executor factory seam to `cli.Runner` using only project-owned types.
Production construction delegates to `internal/adapters/promptkit`; CLI
tests inject a fake factory. The factory creates one executor for an action,
not one per report.
3. Add a project-owned app inspection helper that:
- inspects the exact prompt ID and `report.Definition.PromptVersion`;
- verifies exactly one required `data_package` input with
`application/yaml`;
- verifies the expected JSON Schema output contract and declared default
profile;
- selects `promptkit.profile` when nonblank, otherwise the prompt default;
- inspects that explicit profile;
- rejects a profile requiring a direct API key, because Weatherreporter has
no direct-key configuration; and
- requires a nonblank environment value for any reported `APIKeyEnv`.
4. Return only safe, project-owned inspection values and classified errors.
The helper performs no collection, provider call, or durable write.
5. Unit-test production factory argument mapping, but do not yet wire it into
`generate` or `run`. Stage 8 performs that atomic production cutover.
### Tests
- Cover exact defaults, mutual profile-source exclusion, local endpoint
validation, zero/unlimited and negative concurrency, and timeout validation.
- Cover prompt/profile default and override precedence, prompt contract
mismatch, unsupported direct-key profiles, missing environment credentials,
and safe errors.
- Verify the CLI factory maps embedded assets, external profile source, local
backend, concurrency, and timeout exactly once without exposing Promptkit
types.
- Run:
```sh
go test ./internal/config ./internal/app ./internal/cli \
./internal/adapters/promptkit
git diff --check
```
### Exit Gate
Configuration, engine construction, and pre-collection inspection are
decision-complete and offline-tested behind project-owned seams. Production
generation still follows the old path, so there is no dual execution mode.
## Stage 8: Cut Over Single-Report Execution And Failure Persistence
### Goal
Move all four `generate` commands to Promptkit prepared execution while
preserving report output behavior and inspectable partial failure. Debug CLI
enablement and final summary presentation are deferred to Stage 9.
### Work
1. Construct exactly one executor after configuration is loaded for a
`generate` action and pass it through `app.GenerateRequest`.
2. Isolate the current shared generation function before changing it:
- move the Scriptorium implementation behind a temporary, unexported legacy
batch helper;
- make `GenerateDetailed` call the new Promptkit implementation described
below;
- leave `RunBatchDetailed` calling only the legacy helper until Stage 10;
and
- do not add a runtime switch, fallback, or dual invocation for the same
report.
This temporary split is solely a staging seam. Stage 10 moves batches to
the Promptkit implementation and deletes the helper.
3. Reorder `GenerateDetailed`:
1. resolve the report and RunID;
2. require the injected executor;
3. run the Stage 7 exact prompt/profile/credential inspection; and only then
4. collect weather.
Inspection failures occur before managed run artifacts exist.
4. Implement the new project-owned report-generation core with the injected
executor and the Stage 5 artifact contract:
- build, serialize once, and save the data package;
- use those exact serialized bytes for execution;
- persist a failed preparation receipt and v2 metadata when preparation
fails;
- use the preparation callback to save successful preparation provenance
and metadata before provider work;
- after execution success or completed validation rejection, save exact raw
output, then the execution artifact, then metadata;
- after operational execution failure, save a failure execution receipt and
metadata without inventing raw output;
- on Promptkit validation rejection, return a classified report failure
after preserving raw output and bounded diagnostics;
- on Promptkit validation success, run the existing typed
`internal/generatedtext` validation, normalize/save generated text, build
and save render context, and render managed Markdown as before.
5. Make partial failures return a non-nil `ReportResult` whenever a RunID and
inspectable paths exist. Preserve preparation/execution/raw/normalized/
context/report paths reached before preparation, capacity, generation,
Promptkit validation, domain validation, template, copy, or notification
failure.
6. Remove Scriptorium result values from the single-report app requests,
results, and test fakes used by the generate path. App tests depend only on
`promptexec.Executor`.
Retain the old adapter and configuration only for the still-uncut batch
path.
7. Do not expose `--llm-debug-dir` yet. Pass `CaptureDebug: false` during this
stage; Stage 9 adds debug orchestration without changing provider execution
semantics.
### Tests
- Add representative offline app workflows for each of the four
reports, using real state, generated-text validation, contexts, and
templates with a fake executor.
- Verify inspection and credential checks occur before collection.
- Verify exact profile override/default precedence and exact prompt versions.
- Verify persistence ordering by observing that execution is not called until
the successful preparation artifact and metadata exist.
- Cover preparation failure, callback/state failure, capacity rejection,
credential disappearance at execution, cancellation, deadline, generation
failure, operational validation failure, completed schema rejection,
generated-text domain failure, template failure, output-copy failure, and
notification failure.
- Verify partial results retain every reached normal-artifact path without
prompt or response content.
- Verify single-report commands invoke only Promptkit and batch commands invoke
only the temporary legacy helper during this intermediate stage.
- Run:
```sh
go test ./internal/app ./internal/cli
go test -race ./internal/app ./internal/adapters/promptkit
git diff --check
```
### Exit Gate
Every single-report command uses one Promptkit engine, performs inspection
before collection, persists preparation before provider work, and produces the
same managed Markdown/template and notification outcomes through project-owned
contracts. Sensitive debug capture remains disabled.
## Stage 9: Add Single-Report Debug And CLI Summary Behavior
### Goal
Expose the opt-in debug workflow and finalize single-report CLI success and
partial-failure summaries without expanding the normal artifact boundary.
### Work
1. Parse `--llm-debug-dir PATH` for all four generate commands. Do not add a
YAML debug switch.
2. Validate or create the debug root before prompt inspection or weather
collection. A requested initialization failure is terminal and occurs
before managed run artifacts exist.
3. Pass debug intent through project-owned CLI and app request types. When
enabled, request sensitive debug values from the executor:
- write preparation debug inside the preparation callback, after the normal
preparation artifact is saved and before metadata is finalized and
provider work begins;
- if the debug write fails, return the callback error so `RunPrepared` is
not called;
- write execution debug immediately after receiving a completed execution
result and before continuing with downstream validation/rendering; and
- treat every requested debug write failure as terminal while preserving
already reached normal artifacts.
4. Set `llmDebugPath` only after the per-run debug directory contains at least
one successfully written debug artifact. Never copy sensitive debug content
into a normal artifact, log, error, or summary.
5. Finalize human and JSON CLI summaries around project-owned
`preparationPath`, `executionPath`, and optional `llmDebugPath`. Include
reached paths on partial failure; omit absent paths instead of inventing
them.
6. Preserve existing output behavior: quiet mode suppresses successful human
output, JSON output remains machine-readable, and failures return a
non-zero status with safe classified text.
### Tests
- Add CLI parser and help tests for `--llm-debug-dir` on all four generate
commands and its absence from YAML configuration.
- Verify debug initialization precedes inspection and collection.
- Verify preparation debug persistence precedes provider execution and that a
write failure prevents the model-client call.
- Cover execution debug success/failure, partial normal-artifact retention,
`llmDebugPath` population rules, quiet mode, JSON summaries, and safe
failure text.
- Assert that summaries and routine logs contain no rendered prompt, schema
body, input body, generated body, endpoint, full parameters, or credential.
- Run:
```sh
go test ./internal/app ./internal/cli ./internal/state
go test -race ./internal/app ./internal/adapters/promptkit
git diff --check
```
### Exit Gate
All single-report commands provide the complete Promptkit, v2 persistence,
opt-in debug, and summary behavior required by the roadmap. Debug failure
ordering is enforced without exposing sensitive content.
## Stage 10: Cut Over Batches And Remove Scriptorium
### Goal
Complete the production cutover, share one engine through each sequential
batch, and delete the retired dependency boundary.
### Work
1. Construct one executor in the CLI for each `run morning` or `run evening`
invocation and pass it through `BatchRequest` to every report.
2. Parse and initialize `--llm-debug-dir` for run commands using the same
policy as generate commands.
3. Before collection, inspect the complete candidate set:
- morning: Today, Tomorrow, and Daily;
- evening: Tomorrow and Daily.
Inspect exact prompt versions, validate declared input/output contracts,
resolve unique effective profiles, and enforce credential availability.
Daily inspection occurs before its collection-dependent future dates are
known because every eligible Daily run uses the same exact prompt contract.
4. Collect once, plan the batch as before, and execute every planned report
sequentially through the shared executor, Stage 8 generation core, and
store. Delete the temporary legacy batch helper after this call site moves.
Preserve continuation after independent report failures and existing
batch-notification gating.
5. Include preparation, execution, and optional debug paths in each batch
item, including failed items when those paths were reached.
6. Replace Scriptorium configuration entirely with Promptkit configuration.
Update defaults, validation, examples, config tests, and all construction
sites. Explicitly reject a top-level `scriptorium:` key with an actionable
migration error even though the general YAML loader is currently
permissive; silently ignoring a former execution configuration is unsafe.
Do not translate it or add a dual-run mode. The maintained examples contain
only `promptkit:`.
7. Delete:
- `internal/adapters/scriptorium`;
- the Scriptorium renderer interface and all result/request types;
- the legacy state path members and Scriptorium write methods retained in
Stage 5;
- subprocess fakes and subprocess-specific tests;
- direct-Markdown branches and remaining Scriptorium defaults; and
- dead helpers used only by the old adapter.
8. Run `go mod tidy` and verify the module graph contains Promptkit `v0.4.0`
and no Scriptorium module or local replacement.
### Tests
- Add batch tests proving one factory call/engine per CLI invocation, one
executor shared across all reports, one collection, sequential ordering,
later-report continuation, per-report path retention, and notification only
after complete success.
- Cover a capacity failure for one report followed by a later independent
report and confirm no retry occurs.
- Cover debug grouping for multiple reports and multiple Daily dates.
- Update config tests for exact defaults, mutual exclusivity, local endpoint,
zero/unlimited and negative concurrency, and maintained examples.
- Run:
```sh
go test ./internal/config ./internal/app ./internal/cli
go test -race ./internal/app ./internal/adapters/promptkit
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
### Exit Gate
All production generation paths use Promptkit. A batch owns one engine and
continues sequentially under existing failure policy. No Scriptorium code,
configuration field, subprocess path, or dependency remains.
## Stage 11: Update Canonical Documentation And Complete Verification
### Goal
Make current-state documentation match the completed implementation, remove
migration-only source material, and perform repository-wide verification.
### Work
1. Update canonical current-state owners in the same migration change:
- `docs/development.md` for repository orientation, package map, task
routing, and validation language;
- `docs/policy/architecture.md` for the Promptkit adapter boundary,
generated-text-only flow, prepared execution, and four-report product;
- `docs/policy/testing.md` for Promptkit/provider fakes instead of
subprocess fakes and the offline external-boundary rule;
- `docs/cli.md` for four commands, `--llm-debug-dir`, and renamed summary
fields;
- `docs/config.md` and `examples/` for the exact Promptkit contract;
- `docs/operations.md` for v2 paths, sensitive debug retention and
permissions, preparation/execution lifecycle, and v1 inspection;
- `docs/troubleshooting.md` for inspection, profile/credential,
preparation, capacity, execution, validation, and debug failures;
- relevant `docs/internal/` files for app, CLI, report, prompt input,
generated text, templates, state, collection, briefing, facts, changes,
and package boundaries; and
- `docs/templates.md` for Promptkit-generated prose and the relocated schema
owner.
2. Replace `docs/integrations/scriptorium.md` and
`docs/internal/scriptorium-adapter.md` with canonical Promptkit integration
and adapter documents. The integration document owns the logical
prompt/profile/schema and durable compatibility contract; the internal
document owns construction, mapping, lifecycle, and tests. Link rather than
duplicate the full CLI/config/operations references.
3. Search all non-roadmap current-state documentation, examples, Go code,
tests, help output, and module metadata for stale `scriptorium`,
`local-heavy`, retired report commands, old artifact field names, old
filenames, and old metadata versions. Retain old names only in explicit v1
compatibility code/tests and historical roadmap discussion.
4. Remove `docs/roadmap/scriptorium` after confirming every promoted runtime
asset is represented under `internal/promptassets` and covered by asset
tests. The unused legacy Daily Markdown prompt is deleted with this source
directory.
5. Keep the feature roadmap and this plan as temporary migration documents
while implementation is under review. Once the implementation is accepted,
mark both complete and move every still-useful contract to its canonical
current-state owner before later archival or removal.
6. Review all added links, fenced examples, flags, field names, defaults,
schema IDs, file paths, and version strings against executable sources.
Confirm examples contain no credentials or private infrastructure values.
### Final Verification
Run:
```sh
gofmt -w <all changed Go files>
go mod tidy
go test ./...
go test -race ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Then verify explicitly:
- `go list -m gitea.maximumdirect.net/eric/promptkit` reports `v0.4.0`;
- no committed `go.work`, `replace`, secret fixture, or live-provider test
exists;
- the maintained examples load through config tests;
- all four embedded prompts inspect at exact version `1.0.0`;
- no runtime prompt requests repair attempts;
- no ordinary artifact or CLI summary includes rendered prompts, schema
bodies, input bodies, generated bodies, provider endpoints, or credentials;
- v1 metadata fixtures remain inspectable and new runs write only v2;
- help exposes only Daily, Today, Tomorrow, Hourly, morning, and evening; and
- managed Markdown remains the only Distributor upload source.
### Exit Gate
Every completion criterion in the feature roadmap is demonstrably satisfied.
Current-state documentation describes the implementation rather than the
migration, and the repository passes all final verification commands.
## Open Questions
None. The feature roadmap and this implementation plan contain all product,
architecture, configuration, compatibility, security, sequencing, and
verification decisions required to begin implementation.

View File

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

View File

@@ -14,14 +14,14 @@ source:
| Report | Template | Schema | Prompt ID and source |
| --- | --- | --- | --- |
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `prompts/daily.generated_text.md` |
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `prompts/today.generated_text.md` |
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `prompts/tomorrow.generated_text.md` |
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `prompts/hourly.generated_text.md` |
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `internal/promptassets/assets/prompts/daily/` |
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `internal/promptassets/assets/prompts/today/` |
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `internal/promptassets/assets/prompts/tomorrow/` |
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
The matching schema files are under `internal/reporttemplate/schemas/`. The
generated-text catalog pairs each schema ID with its template ID; keep the
matching report prompt source aligned with that pair.
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.
Shared partials are under `internal/reporttemplate/templates/partials/`:

View File

@@ -17,7 +17,7 @@ or required command and configuration values are absent or malformed.
Diagnostic: Compare the command with [`generate` and `run`](cli.md#commands-and-usage)
and review the configured value named in the error. `generate daily` requires
`--date`; `generate storm` requires both `--start` and `--end`.
`--date`.
Safe fix: Correct only the reported option or configuration value. Use an
absolute Weather API URL and a valid IANA timezone; do not change unrelated

10
go.mod
View File

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

8
go.sum
View File

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

View File

@@ -73,12 +73,6 @@ type RenderRequest struct {
DataPackagePath string
}
type RunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
@@ -94,16 +88,6 @@ type RenderResult struct {
ExitCode int `json:"exitCode"`
}
type RunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
type StructuredRunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
@@ -139,21 +123,6 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
return result, nil
}
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}, "run scriptorium", "scriptorium run")
if err != nil {
if result == nil {
return nil, err
}
return result.runResult(), err
}
return result.runResult(), nil
}
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
@@ -169,18 +138,6 @@ func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*S
return result.structuredRunResult(), nil
}
func (result outputRunResult) runResult() *RunResult {
return &RunResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}
}
func (result outputRunResult) structuredRunResult() *StructuredRunResult {
return &StructuredRunResult{
Command: result.Command,
@@ -225,11 +182,7 @@ func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeCon
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}))
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("%s: %w", executeContext, err)
}
@@ -285,7 +238,7 @@ func (r Runner) renderArgs(req RenderRequest) []string {
return args
}
func (r Runner) runArgs(req RunRequest) []string {
func (r Runner) runArgs(req outputRunRequest) []string {
args := []string{"run"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)

View File

@@ -20,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.markdown_report",
PromptID: "weather.daily_generated_text",
DataPackagePath: "/tmp/data_package.yaml",
})
if err != nil {
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"render",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--prompt", "weather.daily_generated_text",
"--input", "data_package=/tmp/data_package.yaml",
"--format", "json",
}
@@ -57,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.markdown_report",
PromptID: "weather.daily_generated_text",
DataPackagePath: "/tmp/data_package.yaml",
})
if err == nil {
@@ -74,80 +74,6 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
}
}
func TestRunConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 45 * time.Second,
Commands: commands,
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 45*time.Second {
t.Fatalf("timeout = %s, want 45s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.OutputPath != "/tmp/daily.md" {
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
}
}
func TestRunReturnsResultForValidationExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte("# Daily Report\n"),
Stderr: []byte("validation failed"),
ExitCode: 2,
},
},
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err == nil {
t.Fatal("Run() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Run() result = nil, want captured result")
}
if result.ExitCode != 2 {
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
}
if !strings.Contains(err.Error(), "validation failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
@@ -251,33 +177,11 @@ func TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
name string
run func(Runner) (*commonResult, error)
}{
{
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
},
{
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
PromptID: "weather.daily_generated_text",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
@@ -321,7 +225,7 @@ func TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--prompt", "weather.daily_generated_text",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/report.md",
}
@@ -360,32 +264,11 @@ func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) {
run func(Runner) (*commonResult, error)
wantErr string
}{
{
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
wantErr: "scriptorium run exited with code 7: captured stderr",
},
{
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
PromptID: "weather.daily_generated_text",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
@@ -440,20 +323,6 @@ func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
name string
run func(Runner, string, string, string) error
}{
{
name: "Run",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: promptID,
DataPackagePath: dataPackagePath,
OutputPath: outputPath,
})
if result != nil {
return fmt.Errorf("result = %#v, want nil", result)
}
return err
},
},
{
name: "StructuredRun",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
@@ -484,13 +353,13 @@ func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
},
{
name: "data package path",
promptID: "weather.markdown_report",
promptID: "weather.daily_generated_text",
outputPath: "/tmp/report.md",
want: "data package path is required",
},
{
name: "output path",
promptID: "weather.markdown_report",
promptID: "weather.daily_generated_text",
dataPackagePath: "/tmp/data_package.yaml",
want: "output path is required",
},

View File

@@ -33,9 +33,6 @@ const (
ReportToday ReportKind = ReportKind(report.CommandNameToday)
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
)
type BatchKind string
@@ -51,8 +48,6 @@ type GenerateRequest struct {
OutputPath string
Now time.Time
Date time.Time
StormStart time.Time
StormEnd time.Time
Collector Collector
Notifier Notifier
}
@@ -108,7 +103,6 @@ type ReportResult struct {
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
@@ -204,7 +198,6 @@ func batchReportFailures(result *BatchResult) int {
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
}
@@ -285,16 +278,13 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if err != nil {
return nil, err
}
if resolved.Definition.Generated {
return GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier,
})
}
return nil, fmt.Errorf("generate is not implemented")
return GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier,
})
}
func RunBatch(ctx context.Context, req BatchRequest) error {
@@ -335,12 +325,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, planned := range plannedReports {
resolved := planned.Resolved
item := batchReportResult(planned)
@@ -446,11 +430,9 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
return report.Resolved{}, err
}
return registry.Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
StormStart: req.StormStart,
StormEnd: req.StormEnd,
Now: now,
Location: location,
Date: req.Date,
})
}
@@ -607,88 +589,27 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, metadataErr
}
if renderErr != nil {
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
return nil, renderErr
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
reportFacts: reportFacts,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
OutputPath: reportPath,
})
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: store,
Resolved: req.Resolved,
Metadata: metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
GenerationErr: runErr,
noNotify: req.noNotify,
})
if err != nil {
if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), err
}
return renderedReportResult(reportResultRequest{
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
reportFacts: reportFacts,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), nil
renderer: renderer,
})
}
type generatedReportRequest struct {
@@ -852,7 +773,6 @@ type reportResultRequest struct {
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
@@ -875,7 +795,6 @@ func renderedReportResult(req reportResultRequest) *ReportResult {
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
RunResult: req.runResult,
StructuredRunResult: req.structuredRunResult,
GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextResultPath: req.generatedTextResultPath,
@@ -1046,9 +965,6 @@ func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resol
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err
}
if resolved.Definition.ID == report.Storm {
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
}
return values, nil
}
@@ -1359,10 +1275,6 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
switch reportID {
case report.Daily, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds)
case report.Weekend:
return changes.CompareWeekend(previous, current, thresholds)
default:
return nil, nil
}

View File

@@ -174,12 +174,13 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection}
outputPath := filepath.Join(t.TempDir(), "three-day.md")
outputPath := filepath.Join(t.TempDir(), "daily.md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
Report: ReportDaily,
OutputPath: outputPath,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
@@ -189,8 +190,8 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
if result == nil {
t.Fatal("GenerateDetailed() result = nil, want report result")
}
if result.Metadata.ReportID != report.ThreeDay || result.Metadata.RunID == "" {
t.Fatalf("metadata = %#v, want 3-day report metadata with run id", result.Metadata)
if result.Metadata.ReportID != report.Daily || result.Metadata.RunID == "" {
t.Fatalf("metadata = %#v, want daily report metadata with run id", result.Metadata)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
@@ -209,7 +210,8 @@ func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: &recordingCollector{err: wantErr},
})
@@ -272,18 +274,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
Stdout: `{"prepared":true}`,
ExitCode: 0,
},
runResult: &scriptorium.RunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote report",
ExitCode: 0,
OutputPath: "",
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
runBody: "# Daily Report\n\nRain this morning.\n",
}
outputPath := filepath.Join(t.TempDir(), "daily.md")
store := recordingFilesystemStore(t, cfg)
@@ -306,9 +301,6 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.renderRequest.PromptID != "weather.daily_generated_text" {
t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID)
}
@@ -537,8 +529,8 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("renderer calls render=%d structured=%d run=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls render=%d structured=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls)
}
if renderer.renderRequest.PromptID != tt.prompt || renderer.structuredRunRequest.PromptID != tt.prompt {
t.Fatalf("prompt IDs render=%q structured=%q, want %q", renderer.renderRequest.PromptID, renderer.structuredRunRequest.PromptID, tt.prompt)
@@ -655,9 +647,6 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyTestConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00")
if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode)
}
store := recordingFilesystemStore(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
@@ -695,9 +684,6 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
@@ -779,9 +765,6 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if result.StructuredRunResult == nil || result.StructuredRunResult.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("StructuredRunResult = %#v, want captured structured run result", result.StructuredRunResult)
}
if result.RunResult != nil {
t.Fatalf("RunResult = %#v, want nil for generated-text template workflow", result.RunResult)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
}
@@ -1016,72 +999,39 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
}
}
func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *testing.T) {
t.Run("Markdown", func(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
func TestGenerateReportSavesFinalMetadata(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Notify.Distributor.Enabled = false
resolved, store, _, _ := resolveHourlyGeneratedTextFixture(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# 3-Day Outlook\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "" || saved.GeneratedTextPath != "" || saved.RenderContextPath != "" {
t.Fatalf("saved markdown metadata has generated-text fields: %#v", saved)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
t.Run("GeneratedTextTemplate", func(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Notify.Distributor.Enabled = false
resolved, store, _, _ := resolveHourlyGeneratedTextFixture(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
}
})
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
}
}
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
@@ -1615,11 +1565,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1843,142 +1789,6 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
}
}
func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# 3-Day Outlook\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
}
if !ok || len(dayparts) == 0 {
t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
}
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot")
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot")
}
}
func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Weekend Outlook\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
}
if !ok || len(dayparts) == 0 {
t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
}
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want prior weekend snapshot")
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot")
}
}
func TestGenerateStormReportWritesReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportStorm,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T10:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Storm Report\n",
}
outputPath := filepath.Join(t.TempDir(), "storm.md")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderRequest.PromptID != "weather.storm_report" {
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
}
if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
t.Fatal("precip_timing stanza missing")
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
t.Fatalf("data package missing storm content:\n%s", string(data))
}
}
func TestInspectGeneratedReportArtifacts(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
@@ -1986,11 +1796,7 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
@@ -2053,11 +1859,7 @@ func TestInspectPriorSnapshot(t *testing.T) {
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
renderer := successfulRenderer("")
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
t.Fatalf("GenerateReport(prior) error = %v", err)
}
@@ -2304,79 +2106,6 @@ const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","loca
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
func TestResolveGenerateStorm(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T12:00:00-05:00")
start := mustParse("2026-05-29T18:00:00-05:00")
end := mustParse("2026-05-30T06:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: start,
StormEnd: end,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.Storm {
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
}
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
}
}
func TestDistributorTemplateValuesDeriveStormID(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T12:00:00-05:00")
start := mustParse("2026-05-29T18:00:00-05:00")
end := mustParse("2026-05-30T06:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: start,
StormEnd: end,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "")
if err != nil {
t.Fatalf("distributorTemplateValuesForReport() error = %v", err)
}
if values.StormID != "2026-05-29T1800-2026-05-30T0600" {
t.Fatalf("StormID = %q, want storm valid-period stamp", values.StormID)
}
}
func TestDistributorTemplateValuesLeaveStormIDEmptyForOtherReports(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
t.Fatalf("load location: %v", err)
}
resolved, err := report.Resolve(report.Today, report.ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "")
if err != nil {
t.Fatalf("distributorTemplateValuesForReport() error = %v", err)
}
if values.StormID != "" {
t.Fatalf("StormID = %q, want empty for %s", values.StormID, resolved.Definition.ID)
}
}
func TestBuildNotificationRequestUsesReportDefaultBundlePaths(t *testing.T) {
cfg := config.Defaults()
cfg.Location.ID = "home"
@@ -2448,50 +2177,6 @@ func TestBuildNotificationRequestUsesReportDefaultBundlePaths(t *testing.T) {
},
source: "/managed/tomorrow.md",
},
{
id: report.ThreeDay,
req: report.ResolveRequest{
Now: now,
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"three-day/2026-05-29/" + metadata.RunID + ".md",
"three-day/2026-05-29/index.md",
}
},
source: "/managed/three-day.md",
},
{
id: report.Weekend,
req: report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"weekend/2026-05-29/" + metadata.RunID + ".md",
"weekend/2026-05-29/index.md",
}
},
source: "/managed/weekend.md",
},
{
id: report.Storm,
req: report.ResolveRequest{
Now: now,
Location: location,
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
},
want: func(metadata state.Metadata) []string {
return []string{
"storm/2026-05-29T1800-2026-05-30T0600/" + metadata.RunID + ".md",
"storm/2026-05-29T1800-2026-05-30T0600/index.md",
}
},
source: "/managed/storm.md",
},
}
for _, tt := range tests {
@@ -3644,9 +3329,7 @@ EOF
;;
*)
cat > "$output" <<'EOF'
# Generated Report
Prepared report body.
{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}
EOF
;;
esac
@@ -4004,16 +3687,13 @@ type recordingRenderer struct {
runCalls int
structuredRunCalls int
renderRequest scriptorium.RenderRequest
runRequest scriptorium.RunRequest
structuredRunRequest scriptorium.StructuredRunRequest
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
err error
runErr error
structuredRunErr error
runBody string
structuredRunBody string
runBody string
}
type recordingCollector struct {
@@ -4080,12 +3760,10 @@ func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metada
return s.Store.SaveMetadata(ctx, metadata)
}
func successfulRenderer(body string) *recordingRenderer {
func successfulRenderer(_ string) *recordingRenderer {
return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
runBody: body,
}
}
@@ -4173,16 +3851,6 @@ func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequ
return &scriptorium.RenderResult{ExitCode: 0}, nil
}
func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
r.runCalls++
if r.runBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
return nil, err
}
}
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
body := validHourlyGeneratedTextJSON()
@@ -4207,20 +3875,6 @@ func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequ
return r.renderResult, r.err
}
func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
r.runCalls++
r.runRequest = req
if r.runBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
return nil, err
}
}
if r.runResult != nil {
r.runResult.OutputPath = req.OutputPath
}
return r.runResult, r.runErr
}
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
r.structuredRunRequest = req

View File

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

View File

@@ -155,10 +155,10 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
@@ -227,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,18 +73,18 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
}
}
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/three-day/data_package.yaml",
PreflightPath: "/runs/three-day/preflight.json",
ReportPath: "/runs/three-day/report.md",
OutputPath: "/copies/three-day.md",
MetadataPath: "/runs/three-day/metadata.json",
DataPackagePath: "/runs/daily/data_package.yaml",
PreflightPath: "/runs/daily/preflight.json",
ReportPath: "/runs/daily/report.md",
OutputPath: "/copies/daily.md",
MetadataPath: "/runs/daily/metadata.json",
Metadata: state.Metadata{
ReportID: report.ThreeDay,
PromptID: "weather.three_day_outlook",
RunID: "20260529T133000Z_three_day",
ReportID: report.Daily,
PromptID: "weather.daily_generated_text",
RunID: "20260529T133000Z_daily",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
@@ -92,8 +92,8 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
summary := newGenerateSummary(result, nil)
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful daily summary", summary)
}
if summary.Notification != nil || summary.NotificationPath != "" {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
@@ -102,7 +102,7 @@ func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
for _, omitted := range []string{"notification"} {
if strings.Contains(string(data), omitted) {
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
}

View File

@@ -20,9 +20,6 @@ Usage:
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N]
@@ -109,9 +106,7 @@ type commonOptions struct {
type generateOptions struct {
commonOptions
Date string
Start string
End string
Date string
}
type inspectOptions struct {
@@ -248,19 +243,6 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
return app.GenerateRequest{}, commonOptions{}, err
}
}
case app.ReportStorm:
if opts.Start == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --start")
}
if opts.End == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --end")
}
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req.StormStart = period.Start
req.StormEnd = period.End
}
return req, opts.commonOptions, nil
@@ -310,10 +292,6 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
if report == app.ReportStorm {
fs.StringVar(&opts.Start, "start", "", "storm start time")
fs.StringVar(&opts.End, "end", "", "storm end time")
}
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}

View File

@@ -70,27 +70,6 @@ func TestRunUnknownCommand(t *testing.T) {
}
}
func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("storm.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "storm",
"--config", fixture.configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: storm")
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
}
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("tomorrow.md")
@@ -132,58 +111,6 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
}
}
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("three-day.md")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: three_day")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
summary := decodeGenerateSummary(t, output.stdout)
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.ThreeDay {
t.Fatalf("generate summary = %#v, want successful 3-day summary", summary)
}
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
t.Fatalf("summary paths = %#v, want managed artifact paths", summary)
}
if summary.OutputPath != outPath {
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
}
if summary.GeneratedTextRawPath != "" || summary.GeneratedTextResultPath != "" || summary.GeneratedTextPath != "" || summary.RenderContextPath != "" {
t.Fatalf("generated-text paths = %#v, want omitted for markdown report", summary)
}
}
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("weekend.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "weekend",
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: weekend")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
}
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
@@ -197,8 +124,6 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
}
@@ -557,8 +482,6 @@ func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml")
}
@@ -786,8 +709,9 @@ func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "three-day",
"generate", "daily",
"--config", configPath,
"--date", "2026-05-29",
)
if err == nil {
t.Fatal("Run() error = nil, want notification failure")
@@ -975,9 +899,6 @@ func TestResolveGenerateCommands(t *testing.T) {
{name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
}
for _, tt := range tests {
@@ -1001,9 +922,6 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
if name == report.CommandNameDaily || name == report.CommandNameToday {
args = append(args, "--date", "2026-05-29")
}
if name == report.CommandNameStorm {
args = append(args, "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00")
}
req, err := runner.resolveGenerate(args)
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
@@ -1044,8 +962,8 @@ func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
if req.OutputPath != "./hourly.md" {
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
}
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
if !req.Date.IsZero() {
t.Fatalf("Date = %s, want unset for hourly", req.Date)
}
}
@@ -1177,75 +1095,12 @@ func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
}
}
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
func TestResolveGenerateRejectsRetiredReports(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing start error")
}
if !strings.Contains(err.Error(), "requires --start") {
t.Fatalf("error = %q, want missing start", err.Error())
}
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error")
}
if !strings.Contains(err.Error(), "requires --end") {
t.Fatalf("error = %q, want missing end", err.Error())
}
}
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--tz", "America/Chicago",
"--start", "2026-05-29T18:00",
"--end", "2026-05-30T06:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
t.Fatalf("StormStart = %q, want local Chicago time", got)
}
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
t.Fatalf("StormEnd = %q, want local Chicago time", got)
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-29T18:00:00-05:00",
"--end", "2026-05-30T06:00:00-05:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if !req.StormEnd.After(req.StormStart) {
t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart)
}
}
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-30T06:00",
"--end", "2026-05-29T18:00",
})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
}
if !strings.Contains(err.Error(), "end time after start time") {
t.Fatalf("error = %q, want invalid bounds context", err.Error())
for _, name := range []string{"three-day", "weekend", "storm"} {
if _, err := runner.resolveGenerate([]string{name}); err == nil {
t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name)
}
}
}

View File

@@ -275,39 +275,6 @@ reports:
}
}
func TestLoadReportModuleOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
deterministic_modules:
- metadata
weekend_outlook:
deterministic_modules:
- metadata
storm_report:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
}
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
}
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
}
}
func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
@@ -375,35 +342,6 @@ reports:
}
}
func TestLoadReportDistributorPathOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
weekend_outlook:
distributor:
path_templates:
- "weekend/{valid_start_date}/index.md"
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) {
t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay])
}
if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) {
t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend])
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
@@ -683,21 +621,6 @@ reports:
`,
wantErr: `unknown report distributor field "paths"`,
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
three_day:
distributor:
path_templates:
- "three-day/latest.md"
`,
wantErr: "duplicates report override",
},
{
name: "UnknownTemplateVariable",
yaml: `
@@ -754,17 +677,6 @@ reports:
`,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
},
{
name: "NonStormStormIDEmptyPathSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/{storm_id}/index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments",
},
{
name: "EmptyOverrideList",
yaml: `
@@ -790,23 +702,6 @@ reports:
}
}
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/storm-{storm_id}.md"
storm:
distributor:
path_templates:
- "storm/{storm_id}/index.md"
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
}
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := `
reports:
@@ -869,29 +764,6 @@ reports:
},
wantErr: "reports.moon",
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
deterministic_modules:
- metadata
three_day:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"three-day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
"three_day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "duplicates report override",
},
{
name: "UnknownModule",
yaml: `
@@ -1389,38 +1261,37 @@ func TestDistributorTemplateRendering(t *testing.T) {
ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600",
StormID: "2026-06-07T1800-2026-06-08T0600",
BundleID: "weatherreporter.home.daily",
}
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values)
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{valid_start_date}", values)
if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
if bundleID != "weatherreporter.home.daily.2026-06-07" {
t.Fatalf("bundleID = %q, want rendered value", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values)
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{valid_start_stamp}.{bundle_id}", values)
if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
if pipelineID != "weatherreporter.daily.2026-06-07T1800.weatherreporter.home.daily.2026-06-07" {
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
}
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values)
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{valid_end_stamp}.{run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" {
if idempotencyKey != "weatherreporter.home.daily.2026-06-07.2026-06-08T0600.20260607T120000Z" {
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
}
reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
"storm/{storm_id}/index.md",
"daily/{valid_start_date}/index.md",
"{valid_start_date}/{artifact_group}/latest.md",
}, values)
if err != nil {
@@ -1428,7 +1299,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
}
wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
"storm/2026-06-07T1800-2026-06-08T0600/index.md",
"daily/2026-06-07/index.md",
"2026-06-07/daily/latest.md",
}
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
id: weather.daily_generated_text
version: "1.0.0"
default_profile: gemini-flash-latest
description: Daily weather report analysis prompt.
inputs:
- name: data_package
required: true
content_type: application/yaml
description: Structured weather data package
messages:
- role: system
content_file: ../common/system.md
- role: user
content_file: ../common/data_package.user.md
- role: user
content: |
{{input "data_package"}}
- role: user
content_file: ./daily_generated_text.user.md
output:
format: json
validation_mode: json_schema
schema_path: daily.generated_text.schema.json

View File

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

View File

@@ -0,0 +1,23 @@
id: weather.hourly_generated_text
version: "1.0.0"
default_profile: gemini-flash-latest
description: Hourly weather report analysis prompt.
inputs:
- name: data_package
required: true
content_type: application/yaml
description: Structured weather data package
messages:
- role: system
content_file: ../common/system.md
- role: user
content_file: ../common/data_package.user.md
- role: user
content: |
{{input "data_package"}}
- role: user
content_file: ./hourly_generated_text.user.md
output:
format: json
validation_mode: json_schema
schema_path: hourly.generated_text.schema.json

View File

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

View File

@@ -0,0 +1,23 @@
id: weather.today_generated_text
version: "1.0.0"
default_profile: gemini-flash-latest
description: Today's weather report analysis prompt.
inputs:
- name: data_package
required: true
content_type: application/yaml
description: Structured weather data package
messages:
- role: system
content_file: ../common/system.md
- role: user
content_file: ../common/data_package.user.md
- role: user
content: |
{{input "data_package"}}
- role: user
content_file: ./today_generated_text.user.md
output:
format: json
validation_mode: json_schema
schema_path: today.generated_text.schema.json

View File

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

View File

@@ -0,0 +1,23 @@
id: weather.tomorrow_generated_text
version: "1.0.0"
default_profile: gemini-flash-latest
description: Tomorrow's weather report analysis prompt.
inputs:
- name: data_package
required: true
content_type: application/yaml
description: Structured weather data package
messages:
- role: system
content_file: ../common/system.md
- role: user
content_file: ../common/data_package.user.md
- role: user
content: |
{{input "data_package"}}
- role: user
content_file: ./tomorrow_generated_text.user.md
output:
format: json
validation_mode: json_schema
schema_path: tomorrow.generated_text.schema.json

View File

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

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.hourly.generated_text.schema.json",
"title": "Hourly GeneratedText",
"type": "object",
"additionalProperties": false,
"required": ["summary", "forecast_discussion"],
"properties": {
"summary": {"type": "string"},
"forecast_discussion": {"type": "string"},
"precipitation_timing": {"type": "string"},
"confidence": {"type": "string"}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
// Package promptassets owns the embedded Promptkit prompt and schema corpus.
package promptassets
import (
"embed"
"fmt"
"io/fs"
)
//go:embed assets/prompts assets/schemas
var assets embed.FS
var schemaPaths = map[string]string{
"daily": "assets/schemas/daily.generated_text.schema.json",
"hourly": "assets/schemas/hourly.generated_text.schema.json",
"today": "assets/schemas/today.generated_text.schema.json",
"tomorrow": "assets/schemas/tomorrow.generated_text.schema.json",
}
// PromptFS returns the embedded prompt definitions and their referenced files.
func PromptFS() fs.FS {
fsys, err := fs.Sub(assets, "assets/prompts")
if err != nil {
panic(fmt.Sprintf("embedded prompt assets: %v", err))
}
return fsys
}
// SchemaFS returns the embedded generated-text JSON schemas.
func SchemaFS() fs.FS {
fsys, err := fs.Sub(assets, "assets/schemas")
if err != nil {
panic(fmt.Sprintf("embedded schema assets: %v", err))
}
return fsys
}
// Schema returns an independent copy of the canonical schema for id.
func Schema(id string) ([]byte, error) {
path, ok := schemaPaths[id]
if !ok {
return nil, fmt.Errorf("unknown generated text schema %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
}
return append([]byte(nil), data...), nil
}

View File

@@ -0,0 +1,161 @@
package promptassets_test
import (
"context"
"encoding/json"
"io/fs"
"strings"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"gopkg.in/yaml.v3"
)
type promptDefinition struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
DefaultProfile string `yaml:"default_profile"`
Inputs []struct {
Name string `yaml:"name"`
Required bool `yaml:"required"`
ContentType string `yaml:"content_type"`
} `yaml:"inputs"`
Output struct {
Format string `yaml:"format"`
ValidationMode string `yaml:"validation_mode"`
SchemaPath string `yaml:"schema_path"`
RepairAttempts *int `yaml:"repair_attempts"`
} `yaml:"output"`
}
func TestPromptAssetsDeclareTheFourGeneratedTextPrompts(t *testing.T) {
tests := []struct {
path string
id string
schemaID string
}{
{"daily/daily_generated_text.yml", "weather.daily_generated_text", "daily"},
{"today/today_generated_text.yml", "weather.today_generated_text", "today"},
{"tomorrow/tomorrow_generated_text.yml", "weather.tomorrow_generated_text", "tomorrow"},
{"hourly/hourly_generated_text.yml", "weather.hourly_generated_text", "hourly"},
}
definitions := 0
if err := fs.WalkDir(promptassets.PromptFS(), ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() && strings.HasSuffix(path, ".yml") {
definitions++
}
return nil
}); err != nil {
t.Fatalf("walk embedded prompts: %v", err)
}
if definitions != len(tests) {
t.Fatalf("prompt definitions = %d, want %d", definitions, len(tests))
}
for _, tc := range tests {
t.Run(tc.id, func(t *testing.T) {
data, err := fs.ReadFile(promptassets.PromptFS(), tc.path)
if err != nil {
t.Fatalf("read prompt definition: %v", err)
}
var definition promptDefinition
if err := yaml.Unmarshal(data, &definition); err != nil {
t.Fatalf("decode prompt definition: %v", err)
}
if definition.ID != tc.id || definition.Version != "1.0.0" || definition.DefaultProfile != "gemini-flash-latest" {
t.Fatalf("definition = %#v, want %s version 1.0.0 and gemini-flash-latest", definition, tc.id)
}
if len(definition.Inputs) != 1 || definition.Inputs[0].Name != "data_package" || !definition.Inputs[0].Required || definition.Inputs[0].ContentType != "application/yaml" {
t.Fatalf("inputs = %#v, want one required YAML data_package", definition.Inputs)
}
if definition.Output.Format != "json" || definition.Output.ValidationMode != "json_schema" || definition.Output.SchemaPath != tc.schemaID+".generated_text.schema.json" || definition.Output.RepairAttempts != nil {
t.Fatalf("output = %#v, want JSON schema output without repair attempts", definition.Output)
}
if _, err := promptassets.Schema(tc.schemaID); err != nil {
t.Fatalf("Schema(%q) error = %v", tc.schemaID, err)
}
})
}
}
func TestSchemasAreCanonicalAndIndependent(t *testing.T) {
for _, id := range []string{"daily", "today", "tomorrow", "hourly"} {
t.Run(id, func(t *testing.T) {
data, err := promptassets.Schema(id)
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
var schema struct {
ID string `json:"$id"`
Title string `json:"title"`
Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatalf("decode schema: %v", err)
}
if schema.Type != "object" || schema.AdditionalProperties || strings.Join(schema.Required, ",") != "summary,forecast_discussion" {
t.Fatalf("schema = %#v, want strict generated-text object", schema)
}
if _, ok := schema.Properties["confidence"]; !ok {
t.Fatalf("schema properties = %#v, want confidence", schema.Properties)
}
if id == "daily" && (schema.ID != "weatherreporter.daily.generated_text.schema.json" || schema.Title != "Daily GeneratedText") {
t.Fatalf("daily schema identity = %q/%q, want corrected Daily identity", schema.ID, schema.Title)
}
data[0] = 'x'
fresh, err := promptassets.Schema(id)
if err != nil || fresh[0] != '{' {
t.Fatalf("Schema() returned shared data or error: %v", err)
}
})
}
}
func TestPromptkitInspectsEmbeddedPromptsOffline(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{},
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
)
if err != nil {
t.Fatalf("NewEngine() error = %v", err)
}
for _, id := range []string{"weather.daily_generated_text", "weather.today_generated_text", "weather.tomorrow_generated_text", "weather.hourly_generated_text"} {
t.Run(id, func(t *testing.T) {
inspection, err := engine.InspectPrompt(context.Background(), id, "1.0.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != id || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
t.Fatalf("inspection = %#v", inspection)
}
})
}
}
func TestPromptAssetsExcludeRetiredRuntimeSettings(t *testing.T) {
if err := fs.WalkDir(promptassets.PromptFS(), ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
data, err := fs.ReadFile(promptassets.PromptFS(), path)
if err != nil {
return err
}
for _, unwanted := range []string{"local-heavy", "pipeline-weather/", "application/json", "repair_attempts:", "weather.daily_report"} {
if strings.Contains(string(data), unwanted) {
t.Fatalf("%s contains retired runtime setting %q", path, unwanted)
}
}
return nil
}); err != nil {
t.Fatalf("walk embedded prompts: %v", err)
}
}

View File

@@ -101,9 +101,9 @@ func TestValidateRequiresCurrentLocalDate(t *testing.T) {
func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req := validBuildRequest(t)
req.Metadata.RunID = "20260529T100000Z_three_day"
req.Metadata.ReportID = report.ThreeDay
req.Metadata.PromptID = "weather.three_day_outlook"
req.Metadata.RunID = "20260529T100000Z_today"
req.Metadata.ReportID = report.Today
req.Metadata.PromptID = "weather.today_generated_text"
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
@@ -115,8 +115,8 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
if pkg.Report.ID != report.Today {
t.Fatalf("Report.ID = %q, want today", pkg.Report.ID)
}
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")

View File

@@ -12,7 +12,7 @@ func dailyDefinition() Definition {
ID: Daily,
Name: "Daily Report",
PromptID: "weather.daily_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
PromptVersion: "1.0.0",
TemplateID: "daily",
GeneratedTextSchemaID: "daily",
ComparisonStrategy: CompareSameValidDate,
@@ -22,7 +22,6 @@ func dailyDefinition() Definition {
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Daily},
Modules: dailyModules(),
resolve: resolveDaily,

View File

@@ -17,25 +17,13 @@ const (
Today ID = "today"
Tomorrow ID = "tomorrow"
Hourly ID = "hourly"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
)
type ComparisonStrategy string
const (
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
CompareRollingWindow ComparisonStrategy = "rolling_window"
)
type GenerationMode string
const (
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareRollingWindow ComparisonStrategy = "rolling_window"
)
type Batch string
@@ -49,14 +37,13 @@ type Definition struct {
ID ID
Name string
PromptID string
GenerationMode GenerationMode
PromptVersion string
TemplateID string
GeneratedTextSchemaID string
ComparisonStrategy ComparisonStrategy
ArtifactGroup string
BatchOutputName string
DistributorPathTemplates []string
Generated bool
CompatiblePriorIDs []ID
Modules []module.ConfigItem
Morning bool
@@ -90,11 +77,9 @@ func (d Definition) ModuleIDs() []module.ID {
}
type ResolveRequest struct {
Now time.Time
Location *time.Location
Date time.Time
StormStart time.Time
StormEnd time.Time
Now time.Time
Location *time.Location
Date time.Time
}
type Resolved struct {

View File

@@ -14,7 +14,7 @@ func hourlyDefinition() Definition {
ID: Hourly,
Name: "Hourly Report",
PromptID: "weather.hourly_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
PromptVersion: "1.0.0",
TemplateID: "hourly",
GeneratedTextSchemaID: "hourly",
ComparisonStrategy: CompareRollingWindow,
@@ -23,7 +23,6 @@ func hourlyDefinition() Definition {
DistributorPathTemplates: []string{
"hourly/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Hourly},
Modules: hourlyModules(),
resolve: resolveHourly,

View File

@@ -10,9 +10,6 @@ const (
CommandNameToday = "today"
CommandNameTomorrow = "tomorrow"
CommandNameHourly = "hourly"
CommandNameThreeDay = "three-day"
CommandNameWeekend = "weekend"
CommandNameStorm = "storm"
BatchNameMorning = "morning"
BatchNameEvening = "evening"
@@ -28,12 +25,6 @@ func IDForCommandName(name string) (ID, error) {
return Tomorrow, nil
case CommandNameHourly:
return Hourly, nil
case CommandNameThreeDay:
return ThreeDay, nil
case CommandNameWeekend:
return Weekend, nil
case CommandNameStorm:
return Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", name)
}
@@ -45,9 +36,6 @@ func CommandNames() []string {
CommandNameToday,
CommandNameTomorrow,
CommandNameHourly,
CommandNameThreeDay,
CommandNameWeekend,
CommandNameStorm,
}
}
@@ -62,12 +50,6 @@ func IDForConfigKey(key string) (ID, error) {
return Tomorrow, nil
case "hourly":
return Hourly, nil
case "three_day", "three_day_outlook":
return ThreeDay, nil
case "weekend", "weekend_outlook":
return Weekend, nil
case "storm", "storm_report":
return Storm, nil
default:
return "", fmt.Errorf("report config key %q is not a known report", key)
}

View File

@@ -1,890 +1,135 @@
package report
import (
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyValidPeriod(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
func TestResolveRetainedReportPeriods(t *testing.T) {
location := loadTestLocation(t)
now := parseTestTime("2026-05-29T17:45:00-05:00")
date := parseTestTime("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
if err == nil {
t.Fatal("Resolve() error = nil, want explicit date requirement")
}
if !strings.Contains(err.Error(), "requires an explicit date") {
t.Fatalf("Resolve() error = %v, want explicit date requirement", err)
}
_ = resolved
}
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "daily" {
t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "daily" {
t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID)
}
}
func TestTodayValidPeriod(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.today_generated_text" {
t.Fatalf("PromptID = %q, want weather.today_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "today" {
t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "today" {
t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID)
}
}
func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00")
resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "tomorrow" {
t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "tomorrow" {
t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID)
}
}
func TestThreeDayPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
resolved, err := Resolve(ThreeDay, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Definition.ID != Hourly {
t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "hourly" {
t.Fatalf("TemplateID = %q, want hourly", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "hourly" {
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", resolved.Definition.GeneratedTextSchemaID)
}
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
}
func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatalf("load location: %v", err)
}
now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
}
func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
tests := []struct {
name string
now string
start string
end string
id ID
request ResolveRequest
wantStart string
wantEnd string
}{
{name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "tuesday", now: "2026-05-26T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "wednesday", now: "2026-05-27T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "thursday", now: "2026-05-28T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{Daily, ResolveRequest{Now: now, Location: location, Date: date}, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00"},
{Today, ResolveRequest{Now: now, Location: location, Date: date}, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00"},
{Tomorrow, ResolveRequest{Now: now, Location: location}, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00"},
{Hourly, ResolveRequest{Now: now, Location: location}, "2026-05-29T17:45:00-05:00", "2026-05-29T23:45:00-05:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := Resolve(Weekend, ResolveRequest{Now: mustParse(tt.now), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, tt.start, tt.end)
})
}
}
func TestWeekendSundayErrors(t *testing.T) {
location := mustLoadLocation(t)
_, err := Resolve(Weekend, ResolveRequest{Now: mustParse("2026-05-31T08:00:00-05:00"), Location: location})
if err == nil {
t.Fatal("Resolve() error = nil, want Sunday weekend error")
}
if !strings.Contains(err.Error(), "Sunday") {
t.Fatalf("error = %q, want Sunday context", err.Error())
}
}
func TestStormManualPeriodParsingAndValidation(t *testing.T) {
location := mustLoadLocation(t)
period, err := ParseStormPeriod("2026-05-29T18:00", "2026-05-30T06:00:00-05:00", location)
if err != nil {
t.Fatalf("ParseStormPeriod() error = %v", err)
}
assertPeriod(t, period, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
_, err = ParseStormPeriod("2026-05-30T06:00", "2026-05-29T18:00", location)
if err == nil {
t.Fatal("ParseStormPeriod() error = nil, want invalid period error")
}
}
func TestStormResolve(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Storm, ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
if resolved.Definition.ComparisonStrategy != CompareExplicitWindow {
t.Fatalf("ComparisonStrategy = %q, want explicit event window", resolved.Definition.ComparisonStrategy)
}
}
func TestIDForCommandName(t *testing.T) {
tests := []struct {
name string
want ID
}{
{name: "daily", want: Daily},
{name: "today", want: Today},
{name: "tomorrow", want: Tomorrow},
{name: "hourly", want: Hourly},
{name: "three-day", want: ThreeDay},
{name: "weekend", want: Weekend},
{name: "storm", want: Storm},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IDForCommandName(tt.name)
if err != nil {
t.Fatalf("IDForCommandName() error = %v", err)
}
if got != tt.want {
t.Fatalf("IDForCommandName() = %q, want %q", got, tt.want)
}
})
}
if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" {
t.Fatalf("CommandNames() = %s, want stable command names", names)
}
dailyID, err := IDForCommandName("daily")
if err != nil {
t.Fatalf("IDForCommandName(daily) error = %v", err)
}
todayID, err := IDForCommandName("today")
if err != nil {
t.Fatalf("IDForCommandName(today) error = %v", err)
}
if dailyID == todayID {
t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID)
}
if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) {
t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err)
}
}
func TestIDForConfigKey(t *testing.T) {
tests := []struct {
key string
want ID
}{
{key: "daily", want: Daily},
{key: "today", want: Today},
{key: "tomorrow", want: Tomorrow},
{key: "hourly", want: Hourly},
{key: "three_day", want: ThreeDay},
{key: "three-day", want: ThreeDay},
{key: "three_day_outlook", want: ThreeDay},
{key: "three-day-outlook", want: ThreeDay},
{key: "weekend", want: Weekend},
{key: "weekend_outlook", want: Weekend},
{key: "storm", want: Storm},
{key: "storm_report", want: Storm},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got, err := IDForConfigKey(tt.key)
if err != nil {
t.Fatalf("IDForConfigKey() error = %v", err)
}
if got != tt.want {
t.Fatalf("IDForConfigKey() = %q, want %q", got, tt.want)
}
})
}
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
}
retiredDailyKey := strings.Join([]string{"daily", "today"}, "_")
if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) {
t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err)
}
dailyID, err := IDForConfigKey("daily")
if err != nil {
t.Fatalf("IDForConfigKey(daily) error = %v", err)
}
todayID, err := IDForConfigKey("today")
if err != nil {
t.Fatalf("IDForConfigKey(today) error = %v", err)
}
if dailyID == todayID {
t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID)
}
}
func TestBatchForCommandName(t *testing.T) {
tests := []struct {
name string
want Batch
}{
{name: "morning", want: Morning},
{name: "evening", want: Evening},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := BatchForCommandName(tt.name)
if err != nil {
t.Fatalf("BatchForCommandName() error = %v", err)
}
if got != tt.want {
t.Fatalf("BatchForCommandName() = %q, want %q", got, tt.want)
}
})
}
if names := strings.Join(BatchCommandNames(), ","); names != "morning,evening" {
t.Fatalf("BatchCommandNames() = %s, want stable batch command names", names)
}
if _, err := BatchForCommandName("hourly"); err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
t.Fatalf("BatchForCommandName(hourly) error = %v, want unknown batch", err)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {
t.Fatal("Lookup() error = nil, want unknown report error")
}
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
t.Fatalf("error = %q, want unknown report context", err.Error())
}
}
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want)
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" {
t.Fatalf("%s PromptID is empty", definition.ID)
}
if definition.Generated && definition.GenerationMode == "" {
t.Fatalf("%s GenerationMode is empty", definition.ID)
}
if definition.ComparisonStrategy == "" {
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
}
}
}
func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow {
wantTemplate := string(definition.ID)
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
}
if definition.TemplateID != wantTemplate {
t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate)
}
if definition.GeneratedTextSchemaID != wantTemplate {
t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate)
}
continue
}
if definition.GenerationMode != GenerationModeScriptoriumMarkdown {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeScriptoriumMarkdown)
}
if definition.TemplateID != "" {
t.Fatalf("%s TemplateID = %q, want empty for Markdown report", definition.ID, definition.TemplateID)
}
if definition.GeneratedTextSchemaID != "" {
t.Fatalf("%s GeneratedTextSchemaID = %q, want empty for Markdown report", definition.ID, definition.GeneratedTextSchemaID)
}
}
}
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
artifactGroup string
batchOutputName string
generated bool
compatiblePriorIDs []ID
comparisonStrategy ComparisonStrategy
}{
{
id: Daily,
artifactGroup: "daily",
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{Daily},
comparisonStrategy: CompareSameValidDate,
},
{
id: Today,
artifactGroup: "today",
batchOutputName: "today.md",
generated: true,
compatiblePriorIDs: []ID{Today},
comparisonStrategy: CompareSameValidDate,
},
{
id: Tomorrow,
artifactGroup: "tomorrow",
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{Tomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: Hourly,
artifactGroup: "hourly",
batchOutputName: "hourly.md",
generated: true,
compatiblePriorIDs: []ID{Hourly},
comparisonStrategy: CompareRollingWindow,
},
{
id: ThreeDay,
artifactGroup: "three-day",
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
comparisonStrategy: CompareSameValidDate,
},
{
id: Weekend,
artifactGroup: "weekend",
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
comparisonStrategy: CompareWeekendWindow,
},
{
id: Storm,
artifactGroup: "storm",
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
comparisonStrategy: CompareExplicitWindow,
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
resolved, err := Resolve(tt.id, tt.request)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if definition.ArtifactGroup != tt.artifactGroup {
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
}
if definition.BatchOutputName != tt.batchOutputName {
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
}
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if definition.ComparisonStrategy != tt.comparisonStrategy {
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
for _, id := range tt.compatiblePriorIDs {
if !definition.CompatibleWithPrior(id) {
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
}
t.Fatalf("Resolve(%q) error = %v", tt.id, err)
}
assertTestPeriod(t, resolved.ValidPeriod, tt.wantStart, tt.wantEnd)
})
}
}
func TestGeneratedRegistryDefinitionsDeclareDistributorPathDefaults(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if len(definition.DistributorPathTemplates) == 0 {
t.Fatalf("%s DistributorPathTemplates is empty", definition.ID)
}
func TestResolveDailyRequiresDate(t *testing.T) {
_, err := Resolve(Daily, ResolveRequest{Now: parseTestTime("2026-05-29T17:45:00-05:00"), Location: loadTestLocation(t)})
if err == nil || !strings.Contains(err.Error(), "explicit date") {
t.Fatalf("Resolve(Daily) error = %v, want explicit-date error", err)
}
}
func TestRegistryDefinitionsDeclareDefaultDistributorPathTemplates(t *testing.T) {
tests := []struct {
id ID
want []string
}{
{
id: Hourly,
want: []string{
"hourly/index.md",
},
},
{
id: Daily,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
},
},
{
id: Today,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"today/index.md",
},
},
{
id: Tomorrow,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"tomorrow/index.md",
},
},
{
id: ThreeDay,
want: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
},
{
id: Weekend,
want: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
},
{
id: Storm,
want: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
},
}
func TestRegistryContainsOnlyPromptBackedReports(t *testing.T) {
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !reflect.DeepEqual(definition.DistributorPathTemplates, tt.want) {
t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.want)
}
})
}
}
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
tests := []struct {
id ID
want []module.ID
}{
{
id: Daily,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.DailyPlanning,
module.HourlyForecast,
},
},
{
id: Today,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
module.TodayPlanning,
},
},
{
id: Tomorrow,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
},
},
{
id: Hourly,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
{
id: ThreeDay,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
},
{
id: Weekend,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
},
{
id: Storm,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
definitions := registry.All()
if len(definitions) != 4 {
t.Fatalf("Registry.All() length = %d, want 4", len(definitions))
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !reflect.DeepEqual(definition.ModuleIDs(), tt.want) {
t.Fatalf("ModuleIDs() = %#v, want %#v", definition.ModuleIDs(), tt.want)
}
})
}
}
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
base := DefaultRegistry()
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
Daily: {
{ID: module.Metadata},
{ID: module.AlertDigest},
},
})
if err != nil {
t.Fatalf("WithModuleOverrides() error = %v", err)
}
definition, err := overridden.Lookup(Daily)
if err != nil {
t.Fatalf("Lookup(overridden) error = %v", err)
}
if !reflect.DeepEqual(definition.ModuleIDs(), []module.ID{module.Metadata, module.AlertDigest}) {
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
}
defaultDefinition, err := base.Lookup(Daily)
if err != nil {
t.Fatalf("Lookup(default) error = %v", err)
}
if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) {
t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs())
}
if !reflect.DeepEqual(definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates) {
t.Fatalf("overridden DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates)
}
}
func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
_, err := DefaultRegistry().WithModuleOverrides(map[ID][]module.ConfigItem{
ID("unknown"): {{ID: module.Metadata}},
})
if err == nil {
t.Fatal("WithModuleOverrides() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
t.Fatalf("error = %q, want unknown report context", err.Error())
}
}
func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Daily, ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
Date: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != Daily {
t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
}
if metadata.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
}
if metadata.RunID != "20260529T100000.000000000Z_daily_2026-05-29" {
t.Fatalf("RunID = %q, want dated Daily report id", metadata.RunID)
}
}
func TestDailyRunIDIncludesValidDateDisambiguator(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
first, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-05-31T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(first) error = %v", err)
}
second, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-06-01T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(second) error = %v", err)
}
firstRunID := first.Metadata().RunID
secondRunID := second.Metadata().RunID
if firstRunID == secondRunID {
t.Fatalf("Daily RunIDs both = %q, want distinct valid-date suffixes", firstRunID)
}
if firstRunID != "20260529T100000.000000000Z_daily_2026-05-31" {
t.Fatalf("first RunID = %q, want valid-date suffix", firstRunID)
}
if secondRunID != "20260529T100000.000000000Z_daily_2026-06-01" {
t.Fatalf("second RunID = %q, want valid-date suffix", secondRunID)
}
}
func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != Hourly {
t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
}
if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
}
if metadata.RunID != "20260529T101500.000000000Z_hourly" {
t.Fatalf("RunID = %q, want unchanged report id shape", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if period.Start.Format(time.RFC3339) != wantStart {
t.Fatalf("Start = %s, want %s", period.Start.Format(time.RFC3339), wantStart)
}
if period.End.Format(time.RFC3339) != wantEnd {
t.Fatalf("End = %s, want %s", period.End.Format(time.RFC3339), wantEnd)
}
}
func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions))
for _, definition := range definitions {
ids = append(ids, string(definition.ID))
if definition.PromptVersion != "1.0.0" {
t.Fatalf("%s PromptVersion = %q, want 1.0.0", definition.ID, definition.PromptVersion)
}
if definition.TemplateID == "" || definition.GeneratedTextSchemaID == "" {
t.Fatalf("%s template/schema = %q/%q, want both set", definition.ID, definition.TemplateID, definition.GeneratedTextSchemaID)
}
}
if _, err := registry.Lookup(ID("three_day")); err == nil {
t.Fatal("Lookup(three_day) error = nil, want unknown report")
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location {
func TestCommandAndConfigurationNamesRejectRetiredReports(t *testing.T) {
if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly" {
t.Fatalf("CommandNames() = %q", names)
}
for _, name := range []string{"three-day", "weekend", "storm"} {
if _, err := IDForCommandName(name); err == nil {
t.Fatalf("IDForCommandName(%q) error = nil, want unknown command", name)
}
}
for _, key := range []string{"three_day", "three_day_outlook", "weekend", "weekend_outlook", "storm", "storm_report"} {
if _, err := IDForConfigKey(key); err == nil {
t.Fatalf("IDForConfigKey(%q) error = nil, want unknown report", key)
}
}
}
func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) {
tests := []struct {
id ID
comparison ComparisonStrategy
morning bool
evening bool
outputName string
paths []string
}{
{Daily, CompareSameValidDate, false, false, "daily.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md"}},
{Today, CompareSameValidDate, true, false, "today.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "today/index.md"}},
{Tomorrow, CompareSameValidDate, false, true, "tomorrow.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "tomorrow/index.md"}},
{Hourly, CompareRollingWindow, false, false, "hourly.md", []string{"hourly/index.md"}},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition := registry.MustLookup(tt.id)
if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.BatchOutputName != tt.outputName {
t.Fatalf("definition = %#v, want retained report contract", definition)
}
if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") {
t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.paths)
}
})
}
}
func assertTestPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if got := period.Start.Format(time.RFC3339); got != wantStart {
t.Fatalf("period start = %q, want %q", got, wantStart)
}
if got := period.End.Format(time.RFC3339); got != wantEnd {
t.Fatalf("period end = %q, want %q", got, wantEnd)
}
}
func loadTestLocation(t *testing.T) *time.Location {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
t.Fatalf("LoadLocation() error = %v", err)
}
return location
}
func mustParse(value string) time.Time {
func parseTestTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)

View File

@@ -16,9 +16,6 @@ func DefaultRegistry() Registry {
todayDefinition(),
tomorrowDefinition(),
hourlyDefinition(),
threeDayDefinition(),
weekendDefinition(),
stormDefinition(),
}
registry := Registry{definitions: map[ID]Definition{}}
for _, definition := range definitions {
@@ -89,7 +86,7 @@ func (r Registry) MustLookup(id ID) Definition {
}
func (r Registry) All() []Definition {
ids := []ID{Daily, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
ids := []ID{Daily, Today, Tomorrow, Hourly}
out := make([]Definition, 0, len(ids))
for _, id := range ids {
if definition, ok := r.definitions[id]; ok {

View File

@@ -1,74 +0,0 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func stormDefinition() Definition {
return Definition{
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
DistributorPathTemplates: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(),
resolve: resolveStorm,
}
}
func stormModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
)
}
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
if location == nil {
location = time.UTC
}
startTime, err := timeutil.ParseStormTime(start, location)
if err != nil {
return timeutil.Period{}, err
}
endTime, err := timeutil.ParseStormTime(end, location)
if err != nil {
return timeutil.Period{}, err
}
period := timeutil.Period{Start: startTime, End: endTime}
if !period.IsValid() {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return period, nil
}
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
if req.StormStart.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
}
if req.StormEnd.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
}
if !req.StormEnd.After(req.StormStart) {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
}

View File

@@ -1,51 +0,0 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func threeDayDefinition() Definition {
return Definition{
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
DistributorPathTemplates: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(),
Morning: true,
resolve: resolveThreeDay,
}
}
func threeDayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
endDate := localNow.AddDate(0, 0, 3)
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
return timeutil.Period{Start: localNow, End: end}, nil
}

View File

@@ -10,7 +10,7 @@ func todayDefinition() Definition {
ID: Today,
Name: "Today Report",
PromptID: "weather.today_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
PromptVersion: "1.0.0",
TemplateID: "today",
GeneratedTextSchemaID: "today",
ComparisonStrategy: CompareSameValidDate,
@@ -21,7 +21,6 @@ func todayDefinition() Definition {
"daily/{valid_start_date}/index.md",
"today/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Today},
Modules: todayModules(),
Morning: true,

View File

@@ -10,7 +10,7 @@ func tomorrowDefinition() Definition {
ID: Tomorrow,
Name: "Tomorrow Report",
PromptID: "weather.tomorrow_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
PromptVersion: "1.0.0",
TemplateID: "tomorrow",
GeneratedTextSchemaID: "tomorrow",
ComparisonStrategy: CompareSameValidDate,
@@ -21,7 +21,6 @@ func tomorrowDefinition() Definition {
"daily/{valid_start_date}/index.md",
"tomorrow/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Tomorrow},
Modules: tomorrowModules(),
Evening: true,

View File

@@ -1,67 +0,0 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func weekendDefinition() Definition {
return Definition{
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
DistributorPathTemplates: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(),
Morning: true,
resolve: resolveWeekend,
}
}
func weekendModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
weekday := localNow.Weekday()
if weekday == time.Sunday {
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
}
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
if weekday == time.Friday || weekday == time.Saturday {
friday := start.AddDate(0, 0, -1)
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
start = fridayEvening
if localNow.After(start) {
start = localNow
}
}
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
return timeutil.Period{Start: start, End: end}, nil
}

View File

@@ -1,38 +0,0 @@
TASK: You are writing structured prose slots for a dated daily weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on the selected local civil day in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the selected day.
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to the selected day.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
Lead with the most practical local outcome for the selected day. If an active warning is relevant during the report period, lead with the hazard.
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
# forecast_discussion
Use deterministic module facts and narrative products to explain the most useful details for the selected day.
Useful context may include:
- timing of condition changes by daypart or hour
- boundaries, forcing, moisture, instability, or storm mode when supported
- active alerts or SPC outlooks that apply to the location
- planning concerns surfaced by `daily_planning`
- confidence or uncertainty
# precipitation_timing
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.

View File

@@ -1,56 +0,0 @@
TASK: You are writing structured prose slots for a short-term hourly weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on the valid period in `report.valid_period`, typically the next several hours for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 2-3 sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
The summary should typically consist of two sentences.
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
If the forecast indicates a significant shift in conditions over time (e.g., from sunny to overcast), then identify the hour when the shift is most likely to occur. If the conditions are generally similar or stable across the forecast period, then pick a single descriptor (e.g., mostly clear) that best captures the character of the weather.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “The rest of the afternoon is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# forecast_discussion
Use narrative products to explain the “why” behind the local forecast when useful.
Useful context may include:
- synoptic pattern
- fronts or boundaries
- shortwaves, troughs, or ridges
- instability, moisture, shear, forcing, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
# precipitation_timing
Use 1-2 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -1,38 +0,0 @@
TASK: You are writing structured prose slots for a current-day weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on today's valid period in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for today.
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to today.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
Lead with the most practical local outcome for today. If an active warning is relevant during the report period, lead with the hazard.
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
# forecast_discussion
Use deterministic module facts and narrative products to explain the most useful details for the current day.
Useful context may include:
- timing of condition changes by daypart or hour
- boundaries, forcing, moisture, instability, or storm mode when supported
- active alerts or SPC outlooks that apply to the location
- planning concerns surfaced by `today_planning`
- confidence or uncertainty
# precipitation_timing
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.

View File

@@ -1,54 +0,0 @@
TASK: You are writing structured prose slots for a short-term hourly weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on the valid period in `report.valid_period`, typically the next several hours for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. 2-3 sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
The summary should typically consist of two sentences.
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Sunday is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# forecast_discussion
Use narrative products to explain the “why” behind the local forecast when useful.
Useful context may include:
- synoptic pattern
- fronts or boundaries
- shortwaves, troughs, or ridges
- instability, moisture, shear, forcing, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
# precipitation_timing
Use 1-2 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -1,4 +1,4 @@
// Package reporttemplate provides embedded Markdown templates and schemas.
// Package reporttemplate provides embedded Markdown templates and partials.
package reporttemplate
import (
@@ -8,7 +8,7 @@ import (
"text/template"
)
//go:embed templates/*.md.tmpl templates/partials/*.md.tmpl schemas/*.schema.json
//go:embed templates/*.md.tmpl templates/partials/*.md.tmpl
var assets embed.FS
var templates = map[string]string{
@@ -25,13 +25,6 @@ var templatePartials = []string{
"templates/partials/today_daypart_forecast.md.tmpl",
}
var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json",
"today": "schemas/today.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
}
func Template(id string) (string, error) {
path, ok := templates[id]
if !ok {
@@ -44,18 +37,6 @@ func Template(id string) (string, error) {
return string(data), nil
}
func Schema(id string) ([]byte, error) {
path, ok := schemas[id]
if !ok {
return nil, fmt.Errorf("unknown generated text schema %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
}
return append([]byte(nil), data...), nil
}
func Render(id string, data any) ([]byte, error) {
source, err := Template(id)
if err != nil {

View File

@@ -2,9 +2,10 @@ package reporttemplate
import (
"encoding/json"
"os"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
)
func TestTemplateLookup(t *testing.T) {
@@ -59,7 +60,7 @@ func TestTodayTemplateLookup(t *testing.T) {
}
func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly")
data, err := promptassets.Schema("hourly")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
@@ -67,7 +68,7 @@ func TestSchemaLookup(t *testing.T) {
}
func TestTomorrowSchemaLookup(t *testing.T) {
data, err := Schema("tomorrow")
data, err := promptassets.Schema("tomorrow")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
@@ -98,7 +99,7 @@ func TestTomorrowSchemaLookup(t *testing.T) {
}
func TestDailySchemaLookup(t *testing.T) {
data, err := Schema("daily")
data, err := promptassets.Schema("daily")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
@@ -129,7 +130,7 @@ func TestDailySchemaLookup(t *testing.T) {
}
func TestTodaySchemaLookup(t *testing.T) {
data, err := Schema("today")
data, err := promptassets.Schema("today")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
@@ -904,26 +905,11 @@ func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
t.Fatalf("Template() error = %v, want unknown template", err)
}
if _, err := Schema("missing"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "missing"`) {
t.Fatalf("Schema() error = %v, want unknown schema", err)
}
if _, err := Render("missing", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
t.Fatalf("Render() error = %v, want unknown template", err)
}
}
func TestDailyPromptAssetExists(t *testing.T) {
data, err := os.ReadFile("prompts/daily.generated_text.md")
if err != nil {
t.Fatalf("read Daily prompt asset: %v", err)
}
for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} {
if !strings.Contains(string(data), want) {
t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data))
}
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
if err == nil {

View File

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

View File

@@ -1,25 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.hourly.generated_text.schema.json",
"title": "Hourly GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "string"
},
"precipitation_timing": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

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

View File

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

View File

@@ -249,7 +249,7 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
}
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
return nil, nil
}
group := resolved.Definition.ArtifactGroup
@@ -448,24 +448,7 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
if err != nil {
return nil, err
}
if resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return []string{filepath.Dir(paths.Metadata)}, nil
}
root := s.join(s.snapshotsDir, group)
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read snapshot group directory %q: %w", root, err)
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(root, entry.Name()))
}
}
return dirs, nil
return []string{filepath.Dir(paths.Metadata)}, nil
}
func (s *FilesystemStore) join(parts ...string) string {
@@ -540,16 +523,5 @@ func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
}
func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
switch resolved.Definition.ComparisonStrategy {
case report.CompareSameValidDate:
return sameValidDate(metadata, resolved)
case report.CompareWeekendWindow:
return sameWeekendWindow(metadata, resolved)
default:
return false
}
}
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
return resolved.Definition.ComparisonStrategy == report.CompareSameValidDate && sameValidDate(metadata, resolved)
}

View File

@@ -778,60 +778,6 @@ func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
}
}
func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
store := newTestStore(t)
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior 3-day snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior weekend snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior narrowed weekend snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t)
first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
@@ -931,46 +877,6 @@ func resolveTodayAt(t *testing.T, value string) report.Resolved {
return resolved
}
func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.ThreeDay, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func resolveWeekendAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.Weekend, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func resolveHourlyAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")

View File

@@ -60,13 +60,11 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport,
}
if resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID
out.GeneratedTextRawPath = paths.GeneratedTextRaw
out.GeneratedTextResultPath = paths.GeneratedTextResult
out.GeneratedTextPath = paths.GeneratedText
out.RenderContextPath = paths.RenderContext
}
out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID
out.GeneratedTextRawPath = paths.GeneratedTextRaw
out.GeneratedTextResultPath = paths.GeneratedTextResult
out.GeneratedTextPath = paths.GeneratedText
out.RenderContextPath = paths.RenderContext
return out
}

View File

@@ -90,14 +90,3 @@ func ParseLocalDate(value string, location *time.Location) (time.Time, error) {
}
return parsed, nil
}
func ParseStormTime(value string, location *time.Location) (time.Time, error) {
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
parsed, err := time.ParseInLocation(LocalDateTimeLayout, value, location)
if err != nil {
return time.Time{}, fmt.Errorf("parse storm time %q as YYYY-MM-DDTHH:MM or RFC3339: %w", value, err)
}
return parsed, nil
}

View File

@@ -39,22 +39,3 @@ func TestParseLocalDate(t *testing.T) {
t.Fatalf("location = %v, want test location", got.Location())
}
}
func TestParseStormTime(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
local, err := ParseStormTime("2026-05-29T18:00", location)
if err != nil {
t.Fatalf("ParseStormTime(local) error = %v", err)
}
if local.Location() != location {
t.Fatalf("local location = %v, want test location", local.Location())
}
rfc3339, err := ParseStormTime("2026-05-29T18:00:00-05:00", location)
if err != nil {
t.Fatalf("ParseStormTime(rfc3339) error = %v", err)
}
if rfc3339.Format(time.RFC3339) != "2026-05-29T18:00:00-05:00" {
t.Fatalf("rfc3339 = %s, want preserved offset time", rfc3339.Format(time.RFC3339))
}
}