diff --git a/docs/cli.md b/docs/cli.md index 568cada..6c02697 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ``` diff --git a/docs/config.md b/docs/config.md index 53d60d3..b787dc4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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: diff --git a/docs/integrations/scriptorium.md b/docs/integrations/scriptorium.md index 78df078..a746022 100644 --- a/docs/integrations/scriptorium.md +++ b/docs/integrations/scriptorium.md @@ -30,11 +30,10 @@ scriptorium run [--config ] [--profile ] \ [ ...] ``` -`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 `` 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 +`` controls that output. ## Inputs and Outputs diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index 9683649..ffbc0fb 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -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. diff --git a/docs/internal/changes.md b/docs/internal/changes.md index 086e504..849c6fa 100644 --- a/docs/internal/changes.md +++ b/docs/internal/changes.md @@ -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 diff --git a/docs/internal/facts.md b/docs/internal/facts.md index 2779181..035a899 100644 --- a/docs/internal/facts.md +++ b/docs/internal/facts.md @@ -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 diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index 78e3c7f..9cea375 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -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. diff --git a/docs/internal/module.md b/docs/internal/module.md index e232303..439bb25 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -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 diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index d9a29ff..a45aac5 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -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 diff --git a/docs/internal/state.md b/docs/internal/state.md index c58730d..ea8ccc5 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -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 diff --git a/docs/operations.md b/docs/operations.md index f7a32dd..84a3f12 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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///distributor..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 diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index eefde26..a481885 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -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. diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index af2183e..accbc31 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md new file mode 100644 index 0000000..a7b1374 --- /dev/null +++ b/docs/roadmap/implementation.md @@ -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///prompt_preparation..json`; + - execution: + `snapshots///prompt_execution..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 `////`; + - 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 +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. diff --git a/docs/roadmap/promptkit.md b/docs/roadmap/promptkit.md index ee2cf46..ce28936 100644 --- a/docs/roadmap/promptkit.md +++ b/docs/roadmap/promptkit.md @@ -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 ` 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 + `////`. +- 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..json`, and is referenced by + `preparationPath`. +- `PromptExecutionArtifact` uses schema version + `weatherreporter.prompt_execution.v1`, is written as + `prompt_execution..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. diff --git a/docs/templates.md b/docs/templates.md index 8bee08d..a6b4db0 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -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/`: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 02b7104..b66703d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 diff --git a/go.mod b/go.mod index 5d64a6c..b9ed867 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 7f0f065..91401f3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk= gitea.maximumdirect.net/eric/distributor v0.5.0/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= diff --git a/internal/adapters/scriptorium/runner.go b/internal/adapters/scriptorium/runner.go index 3c6017f..fef0dcd 100644 --- a/internal/adapters/scriptorium/runner.go +++ b/internal/adapters/scriptorium/runner.go @@ -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) diff --git a/internal/adapters/scriptorium/runner_test.go b/internal/adapters/scriptorium/runner_test.go index 0b0a3ce..267de2a 100644 --- a/internal/adapters/scriptorium/runner_test.go +++ b/internal/adapters/scriptorium/runner_test.go @@ -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", }, diff --git a/internal/app/app.go b/internal/app/app.go index 4a06bcc..24f3010 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0b070e4..afe665e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 diff --git a/internal/app/batch_plan_test.go b/internal/app/batch_plan_test.go index 63c915e..e989d70 100644 --- a/internal/app/batch_plan_test.go +++ b/internal/app/batch_plan_test.go @@ -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)...) diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 855142b..c155a95 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -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) } } diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 2f2c7df..397c04e 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -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}) diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 7e551d4..9192260 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -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, diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 7f54f48..83b3e42 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -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`) { diff --git a/internal/changes/three_day.go b/internal/changes/three_day.go deleted file mode 100644 index 4980f71..0000000 --- a/internal/changes/three_day.go +++ /dev/null @@ -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 -} diff --git a/internal/changes/three_day_test.go b/internal/changes/three_day_test.go deleted file mode 100644 index 1b762f2..0000000 --- a/internal/changes/three_day_test.go +++ /dev/null @@ -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, - }, - }}) -} diff --git a/internal/changes/weekend.go b/internal/changes/weekend.go deleted file mode 100644 index df45f05..0000000 --- a/internal/changes/weekend.go +++ /dev/null @@ -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_") -} diff --git a/internal/changes/weekend_test.go b/internal/changes/weekend_test.go deleted file mode 100644 index 78ba784..0000000 --- a/internal/changes/weekend_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/cli/result_test.go b/internal/cli/result_test.go index 3cd8058..8ef2726 100644 --- a/internal/cli/result_test.go +++ b/internal/cli/result_test.go @@ -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)) } diff --git a/internal/cli/root.go b/internal/cli/root.go index 2f48c2b..245ac2f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index acaa539..24b2cdf 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -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) + } } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e08ef42..41b5a91 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") { diff --git a/internal/config/notify_templates.go b/internal/config/notify_templates.go index da1f40d..87052af 100644 --- a/internal/config/notify_templates.go +++ b/internal/config/notify_templates.go @@ -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: diff --git a/internal/config/reports.go b/internal/config/reports.go index 9ca4d32..bac707a 100644 --- a/internal/config/reports.go +++ b/internal/config/reports.go @@ -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, } } diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 99161c0..0977014 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -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 } diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index c95f5ac..77e23ae 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -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 { diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index caccddd..d94320b 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -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 diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index 501a96d..39afff0 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -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"}`) diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index f291416..43a7612 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -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) } diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index 8fe8865..d73b70b 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -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", }) diff --git a/internal/promptassets/assets/prompts/common/data_package.user.md b/internal/promptassets/assets/prompts/common/data_package.user.md new file mode 100644 index 0000000..700f508 --- /dev/null +++ b/internal/promptassets/assets/prompts/common/data_package.user.md @@ -0,0 +1,81 @@ +Your task is to generate a local weather forecast analysis from the following YAML data package, which is prepared by the weatherreporter application. + +Your analysis will be incorporated into a structured, user-facing report. The report may be for today, tomorrow, or a future date. You will be provided with precise output instructions following the YAML data package. + +# SOURCE ROLES AND WEIGHTING + +Use `report` and `briefing.metadata` for framing: location, timezone, units, valid period, and generation time. Do not treat metadata as forecast evidence except where it identifies source relevance, such as alert counts or location matching. + +For weather interpretation, think in four source layers, in this order: + +## 1. Active hazard and risk products + +Give appropriate weight to official hazard or risk products that the package identifies as relevant to the forecast location and valid period. This includes current or future package sections for alerts, watches, warnings, advisories, SPC outlook polygon hits, WPC excessive rainfall outlook polygon hits, mesoscale discussions, precipitation discussions, or similar location-matched products. + +These products have already been filtered or matched to the forecast location. Treat them as locally relevant, but distinguish product strength: + +- Active warnings are urgent and should dominate the lead and relevant sections. +- Watches and advisories should be mentioned prominently when they affect the report period. +- Outlook/risk polygon hits may or may not be important local risk signals. Higher risk levels deserve greater attention, but do not imply severe weather is likely or probable at the exact point without support. +- Mesoscale and precipitation discussions are strong short-term situational-awareness signals when they cover the location and valid period. + +For the current schema, use `briefing.applicable_risk_products.alert_digest` and `briefing.metadata.alerts` to determine whether relevant local alerts exist. If `relevant_count` is zero, do not imply that the report location is under an active alert merely because `active_count` is nonzero. + +## 2. Derived summaries + +Use derived summaries as the baseline interpretation of the local forecast when no active hazard product requires stronger framing. + +- Use `briefing.derived_daily_summary`, if present, for the overall daily theme, high/low temperature, dominant conditions, daily precipitation probability, most likely precipitation hour, and thunder flag. +- Use `briefing.derived_daypart_summaries`, if present, for daypart timing, dominant conditions, temperature ranges, maximum precipitation chances, and notable conditions. +- Use `briefing.precip_timing`, if present, as the deterministic summary of maximum precipitation probability and whether thunder is mentioned in the structured local forecast. +- Use `briefing.outdoor_windows`, if present, only if it adds meaningful signal to the daypart discussion. Do not turn the report into outdoor-planning advice. + +## 3. Narrative products + +Use `briefing.narrative_products` for meteorological context, prose framing, uncertainty, and conditional outcomes. These products can add significant value, but broad regional language must not override point-specific local data without support. + +- Use `briefing.narrative_products.narrative_forecast.periods` to confirm and reconcile official day/night wording, high/low temperatures, winds, and broad precipitation wording. +- Use `briefing.narrative_products.weather_story` and `briefing.narrative_products.area_forecast_discussion.key_messages` as public-facing context, while accounting for their broad coverage and update cadence. +- Use `briefing.narrative_products.area_forecast_discussion.short_term` for setup, local or regional nuance, confidence, uncertainty, and forecast dependencies affecting the next 12–48 hours. +- Use `briefing.narrative_products.area_forecast_discussion.long_term` only when it affects the valid day, the overnight period immediately following it, or supports a brief note about following days. +- Use `briefing.narrative_products.spc_convective_discussion.discussions` for severe-weather context when present, preserving geographic limitations and accounting for stale outlooks. + +## 4. Raw underlying data + +Use `briefing.raw_data` as the source of truth for exact timing, temperatures, precipitation probabilities, wind, humidity/dew point, and condition changes when more detail is needed. `briefing.raw_data.hourly_forecast.periods` is the most granular local forecast source. Use `briefing.raw_data.current_conditions` only as generation-time context. + +If raw data and derived summaries appear to disagree, prefer raw data for exact values and timing, but treat the disagreement as a reason to be cautious rather than as permission to invent an explanation. + +# CONFLICT RESOLUTION + +When sources differ, ask: + +1. Which source is most local to the forecast point? +2. Which source is valid for the report period or near-term window? +3. Which source is most authoritative for the type of claim being made? +4. Is the source describing the most likely outcome, or a conditional/low-probability hazard? + +Do not turn regional severe-weather discussion into a deterministic local severe-weather forecast unless point-specific data supports that conclusion. Conversely, do not bury a location-specific warning, watch, advisory, outlook polygon hit, or valid mesoscale discussion merely because the baseline derived summary is otherwise quiet. + +# HAZARD AND PRECIPITATION RULES + +Mention a hazard only to the extent supported by location-specific products, local structured forecast data, or clearly applicable narrative text. Preserve product strength, uncertainty, geography, and timing. Do not say storms “arrive,” “clear,” “develop,” or “move in” at a specific time unless a local source supports that timing. + +Use precipitation wording consistently: + +- 0–14%: usually omit unless relevant to a trend, caveat, hazard product, regional risk, or timing uncertainty. +- 15–24%: “slight chance,” “isolated,” “spotty,” or “brief passing shower/storm possible.” +- 25–39%: “chance,” “scattered,” or “some showers/storms possible.” +- 40–59%: “good chance” or “showers/storms likely enough to plan around.” +- 60%+: “likely,” “wet,” or “unsettled,” if consistent with the narrative forecast. + +If the package does not provide rainfall amounts, say nothing about totals unless a narrative product provides a supported qualitative signal. Do not invent QPF. If local precipitation chances are low and no meaningful local impacts are expected, do not imply thunderstorms are likely solely because regional precipitation or severe weather appears in narrative text. + +# STYLE RULES + +- Plainspoken, precise, and weather-literate. +- Compact, but not shallow. +- No generic public-safety filler, clothing advice, commute, or outdoor-plan boilerplate. +- No unsupported precision or apologies for missing data. +- Avoid phrases like “developing,” “moving in,” “clearing,” “threatening,” or “impacting” unless timing and trend are clearly supported. +- Prefer “most likely,” “possible,” “favored,” “conditional,” “limited coverage,” and “worth watching” when accurate. diff --git a/internal/promptassets/assets/prompts/common/system.md b/internal/promptassets/assets/prompts/common/system.md new file mode 100644 index 0000000..f2f81a1 --- /dev/null +++ b/internal/promptassets/assets/prompts/common/system.md @@ -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. diff --git a/internal/promptassets/assets/prompts/daily/daily_generated_text.user.md b/internal/promptassets/assets/prompts/daily/daily_generated_text.user.md new file mode 100644 index 0000000..c481216 --- /dev/null +++ b/internal/promptassets/assets/prompts/daily/daily_generated_text.user.md @@ -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. diff --git a/internal/promptassets/assets/prompts/daily/daily_generated_text.yml b/internal/promptassets/assets/prompts/daily/daily_generated_text.yml new file mode 100644 index 0000000..7959bde --- /dev/null +++ b/internal/promptassets/assets/prompts/daily/daily_generated_text.yml @@ -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 diff --git a/internal/promptassets/assets/prompts/hourly/hourly_generated_text.user.md b/internal/promptassets/assets/prompts/hourly/hourly_generated_text.user.md new file mode 100644 index 0000000..5bcac60 --- /dev/null +++ b/internal/promptassets/assets/prompts/hourly/hourly_generated_text.user.md @@ -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. diff --git a/internal/promptassets/assets/prompts/hourly/hourly_generated_text.yml b/internal/promptassets/assets/prompts/hourly/hourly_generated_text.yml new file mode 100644 index 0000000..21b4806 --- /dev/null +++ b/internal/promptassets/assets/prompts/hourly/hourly_generated_text.yml @@ -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 diff --git a/internal/promptassets/assets/prompts/today/today_generated_text.user.md b/internal/promptassets/assets/prompts/today/today_generated_text.user.md new file mode 100644 index 0000000..5270f55 --- /dev/null +++ b/internal/promptassets/assets/prompts/today/today_generated_text.user.md @@ -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. diff --git a/internal/promptassets/assets/prompts/today/today_generated_text.yml b/internal/promptassets/assets/prompts/today/today_generated_text.yml new file mode 100644 index 0000000..31bc92f --- /dev/null +++ b/internal/promptassets/assets/prompts/today/today_generated_text.yml @@ -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 diff --git a/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.user.md b/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.user.md new file mode 100644 index 0000000..cf3e236 --- /dev/null +++ b/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.user.md @@ -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. diff --git a/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.yml b/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.yml new file mode 100644 index 0000000..07563ed --- /dev/null +++ b/internal/promptassets/assets/prompts/tomorrow/tomorrow_generated_text.yml @@ -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 diff --git a/internal/promptassets/assets/schemas/daily.generated_text.schema.json b/internal/promptassets/assets/schemas/daily.generated_text.schema.json new file mode 100644 index 0000000..5091fc0 --- /dev/null +++ b/internal/promptassets/assets/schemas/daily.generated_text.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "weatherreporter.daily.generated_text.schema.json", + "title": "Daily GeneratedText", + "type": "object", + "additionalProperties": false, + "required": ["summary", "forecast_discussion"], + "properties": { + "summary": {"type": "string"}, + "forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "precipitation_timing": {"type": "string"}, + "confidence": {"type": "string"} + } +} diff --git a/internal/promptassets/assets/schemas/hourly.generated_text.schema.json b/internal/promptassets/assets/schemas/hourly.generated_text.schema.json new file mode 100644 index 0000000..6b49afb --- /dev/null +++ b/internal/promptassets/assets/schemas/hourly.generated_text.schema.json @@ -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"} + } +} diff --git a/internal/promptassets/assets/schemas/today.generated_text.schema.json b/internal/promptassets/assets/schemas/today.generated_text.schema.json new file mode 100644 index 0000000..5ba5da7 --- /dev/null +++ b/internal/promptassets/assets/schemas/today.generated_text.schema.json @@ -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"} + } +} diff --git a/internal/promptassets/assets/schemas/tomorrow.generated_text.schema.json b/internal/promptassets/assets/schemas/tomorrow.generated_text.schema.json new file mode 100644 index 0000000..c7f121e --- /dev/null +++ b/internal/promptassets/assets/schemas/tomorrow.generated_text.schema.json @@ -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"} + } +} diff --git a/internal/promptassets/promptassets.go b/internal/promptassets/promptassets.go new file mode 100644 index 0000000..291b012 --- /dev/null +++ b/internal/promptassets/promptassets.go @@ -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 +} diff --git a/internal/promptassets/promptassets_test.go b/internal/promptassets/promptassets_test.go new file mode 100644 index 0000000..8f1991b --- /dev/null +++ b/internal/promptassets/promptassets_test.go @@ -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) + } +} diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index 7e07554..4718ae3 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -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") diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 0266508..93284c7 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -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, diff --git a/internal/report/definition.go b/internal/report/definition.go index b722749..95c28ad 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -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 { diff --git a/internal/report/hourly_report.go b/internal/report/hourly_report.go index 9188ec1..6860bdf 100644 --- a/internal/report/hourly_report.go +++ b/internal/report/hourly_report.go @@ -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, diff --git a/internal/report/names.go b/internal/report/names.go index 3f4eead..0b3fb74 100644 --- a/internal/report/names.go +++ b/internal/report/names.go @@ -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) } diff --git a/internal/report/period_test.go b/internal/report/period_test.go index 60e1d38..818d087 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -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) diff --git a/internal/report/registry.go b/internal/report/registry.go index 573c835..cfd7242 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -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 { diff --git a/internal/report/storm_report.go b/internal/report/storm_report.go deleted file mode 100644 index 736e495..0000000 --- a/internal/report/storm_report.go +++ /dev/null @@ -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 -} diff --git a/internal/report/three_day_report.go b/internal/report/three_day_report.go deleted file mode 100644 index 5ac094c..0000000 --- a/internal/report/three_day_report.go +++ /dev/null @@ -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 -} diff --git a/internal/report/today_report.go b/internal/report/today_report.go index 048df15..89414db 100644 --- a/internal/report/today_report.go +++ b/internal/report/today_report.go @@ -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, diff --git a/internal/report/tomorrow_report.go b/internal/report/tomorrow_report.go index cb3bfbe..d77a36d 100644 --- a/internal/report/tomorrow_report.go +++ b/internal/report/tomorrow_report.go @@ -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, diff --git a/internal/report/weekend_report.go b/internal/report/weekend_report.go deleted file mode 100644 index 6cda31a..0000000 --- a/internal/report/weekend_report.go +++ /dev/null @@ -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 -} diff --git a/internal/reporttemplate/prompts/daily.generated_text.md b/internal/reporttemplate/prompts/daily.generated_text.md deleted file mode 100644 index 2a6e9ac..0000000 --- a/internal/reporttemplate/prompts/daily.generated_text.md +++ /dev/null @@ -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. diff --git a/internal/reporttemplate/prompts/hourly.generated_text.md b/internal/reporttemplate/prompts/hourly.generated_text.md deleted file mode 100644 index e66129c..0000000 --- a/internal/reporttemplate/prompts/hourly.generated_text.md +++ /dev/null @@ -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. diff --git a/internal/reporttemplate/prompts/today.generated_text.md b/internal/reporttemplate/prompts/today.generated_text.md deleted file mode 100644 index eaffe96..0000000 --- a/internal/reporttemplate/prompts/today.generated_text.md +++ /dev/null @@ -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. diff --git a/internal/reporttemplate/prompts/tomorrow.generated_text.md b/internal/reporttemplate/prompts/tomorrow.generated_text.md deleted file mode 100644 index 321cbe1..0000000 --- a/internal/reporttemplate/prompts/tomorrow.generated_text.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index ea689c8..4fe86da 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -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 { diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 2044643..ecf4e59 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -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 { diff --git a/internal/reporttemplate/schemas/daily.generated_text.schema.json b/internal/reporttemplate/schemas/daily.generated_text.schema.json deleted file mode 100644 index a8f4bc9..0000000 --- a/internal/reporttemplate/schemas/daily.generated_text.schema.json +++ /dev/null @@ -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" - } - } -} diff --git a/internal/reporttemplate/schemas/hourly.generated_text.schema.json b/internal/reporttemplate/schemas/hourly.generated_text.schema.json deleted file mode 100644 index 34f2bca..0000000 --- a/internal/reporttemplate/schemas/hourly.generated_text.schema.json +++ /dev/null @@ -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" - } - } -} diff --git a/internal/reporttemplate/schemas/today.generated_text.schema.json b/internal/reporttemplate/schemas/today.generated_text.schema.json deleted file mode 100644 index d7e4944..0000000 --- a/internal/reporttemplate/schemas/today.generated_text.schema.json +++ /dev/null @@ -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" - } - } -} diff --git a/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json b/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json deleted file mode 100644 index 563b851..0000000 --- a/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json +++ /dev/null @@ -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" - } - } -} diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index a8f60f3..552c6d6 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -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) } diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 733e984..d5a9e3a 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -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") diff --git a/internal/state/metadata.go b/internal/state/metadata.go index ac7b304..7ffaa5c 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -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 } diff --git a/internal/timeutil/parse.go b/internal/timeutil/parse.go index b609456..0e4715d 100644 --- a/internal/timeutil/parse.go +++ b/internal/timeutil/parse.go @@ -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 -} diff --git a/internal/timeutil/parse_test.go b/internal/timeutil/parse_test.go index 00d01bd..b816747 100644 --- a/internal/timeutil/parse_test.go +++ b/internal/timeutil/parse_test.go @@ -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)) - } -}