10 Commits

29 changed files with 1775 additions and 2677 deletions

View File

@@ -182,8 +182,10 @@ snapshot exists and a threshold is crossed.
report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
accepted.
`weekend`, and `storm`. Canonical report IDs and accepted aliases are also
valid, including `daily_today`, `three_day_outlook`, `weekend_outlook`, and
`storm_report`. Hyphens and underscores are treated equivalently in report
keys.
Each report entry supports:

View File

@@ -94,27 +94,27 @@ Single-report generation shares this setup:
For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path.
13. Copies the managed report to the requested `--out` path when provided.
14. Saves metadata with the managed report path.
15. If distributor notification is enabled, notifies using the managed report
path as the source file.
16. Saves a distributor notification debug artifact and updates metadata with
its path.
For `generated_text_template` reports, generation then:
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
13. Saves the structured Scriptorium run result.
14. Validates and saves normalized generated text.
15. Builds and saves a typed render context.
16. Renders Markdown from the embedded template to the managed report path.
17. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path.
18. Copies the managed report to the requested `--out` path when provided.
19. If distributor notification is enabled, notifies using the managed report
path as the source file.
20. Saves a distributor notification debug artifact and updates metadata with
its path.
12. Looks up the generated-text catalog entry for the report schema/template
IDs.
13. Runs structured Scriptorium generation to the raw generated-text JSON path.
14. Saves the structured Scriptorium run result.
15. Validates and saves normalized generated text.
16. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization:
1. Copies the managed report to the requested `--out` or `--out-dir` path when
provided.
2. Saves final metadata with the managed report path and any generated-text
artifact paths already produced.
3. If distributor notification is enabled, notifies using the managed report
path as the source file.
4. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report

View File

@@ -7,7 +7,9 @@ This document describes structured generated-text handling in
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. The implemented contracts are Tomorrow Report and Hourly Report.
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Tomorrow Report and Hourly Report.
## Inputs And Outputs
@@ -25,6 +27,8 @@ Outputs:
- normalized stable JSON for validated generated text
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use
`generated_text_template`
The hourly generated text JSON accepts:
@@ -60,10 +64,12 @@ JSON when blank.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It owns generated-text catalog lookup for schema/template combinations.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or render templates directly in production
workflows.
definitions, compare snapshots, or own embedded template/schema files.
- It renders through `internal/reporttemplate`; embedded asset lookup remains
in `internal/reporttemplate`.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
@@ -77,6 +83,8 @@ JSON when blank.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
- Unsupported generated-text schema IDs, template IDs, or schema/template
combinations fail during catalog lookup with report ID context.
## Tests
@@ -84,11 +92,14 @@ Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Generated-text-template reports must have one catalog entry matching their
report definition schema and template IDs.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.

View File

@@ -6,9 +6,10 @@ membership, output naming, artifact grouping, and comparison declarations in
## Purpose
`internal/report` is the canonical source for report definitions. App, state,
module building, and CLI wiring consume resolved definitions instead of owning
report identity policy themselves.
`internal/report` is the canonical source for report definitions, public
command names, config-key aliases, and batch command names. App, config, state,
module building, and CLI wiring consume report-owned helpers and resolved
definitions instead of owning report identity policy themselves.
## Definition Fields
@@ -26,6 +27,12 @@ Each report definition declares:
- morning or evening batch membership
- default ordered module composition
Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `tomorrow`, `hourly`, `three-day`,
`weekend`, and `storm`. Config keys also accept underscore and legacy
descriptive aliases such as `daily_today`, `three_day_outlook`,
`weekend_outlook`, and `storm_report`.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Tomorrow
Report and Hourly Report declare `generated_text_template`; the app uses their
@@ -65,19 +72,21 @@ must be after start time.
## Boundaries
`internal/report` defines report metadata and time coverage. It does not fetch
weather data, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
`internal/report` defines report metadata, public report names, batch command
names, and time coverage. It does not fetch weather data, build module values,
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
The CLI parses flags and command structure, then uses report-owned helpers for
report and batch command names. Config loading uses report-owned helpers for
report override keys.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `tomorrow` and `hourly`, or
canonical report IDs such as `daily_today`.
module overrides can use short keys such as `tomorrow` and `hourly`, canonical
report IDs such as `daily_today`, or accepted aliases such as
`three_day_outlook`.
## Batch Membership
@@ -111,6 +120,8 @@ Inspect:
## Invariants
- Report selection goes through the registry.
- Public command names, config-key aliases, and batch command names are owned
by `internal/report`.
- Direct Markdown reports have empty template and generated-text schema IDs.
- Generated-text-template reports declare prompt, template, and schema IDs in
their report definition.

View File

@@ -39,8 +39,9 @@ validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration decides which template and schema IDs apply to a report through
`internal/report` definitions.
orchestration uses `internal/generatedtext` catalog lookup to connect
`internal/report` definition schema/template IDs to the matching validator,
render-context builder, and embedded assets.
## Template Contracts

View File

@@ -1,612 +0,0 @@
# Code Quality And Deduplication Audit
## Executive Summary
The codebase is in good pre-release shape. The major architecture is coherent: configuration loading is centralized, external systems are behind adapters, reports and modules have explicit registries, state paths are mostly centralized, and the application orchestration is readable. I do not see a major architectural risk that requires a broad rewrite before the next release.
The top three cleanup targets are:
1. Generated-text/template dispatch is spread across `internal/report`, `internal/app`, `internal/generatedtext`, and `internal/reporttemplate`.
2. Final report completion logic is duplicated between the direct Markdown generation path and the generated-text-template path.
3. Report name, command name, config key, and alias resolution is implemented in multiple packages.
The codebase is ready for a limited cleanup pass. The highest-value work should be small, behavior-preserving centralization around catalogs, finalization helpers, and validation boundaries. Avoid a workflow engine, plugin system, or broad CLI redesign.
## Repository Map Reviewed
Reviewed source areas:
- `cmd/weatherreporter`: executable entry point.
- `internal/cli`: standard-library CLI parsing, command dispatch, inspect command setup, output handling.
- `internal/app`: end-to-end orchestration for fetch, facts, module snapshots, prompt input, generated text, report rendering, state, output copies, and notifications.
- `internal/config`: defaults, YAML loading, CLI overrides, report module overrides, secrets directory, distributor notification templates, validation.
- `internal/report`: report IDs, definitions, registry, valid-period resolution, batch definitions.
- `internal/module` and `internal/briefing`: module IDs, module registry, module builders, missing-data behavior, prompt-facing module output.
- `internal/facts`, `internal/forecast`, and `internal/weatherdata`: collected and derived fact contracts, forecast derivation, normalized upstream data.
- `internal/adapters/weatherapi`: HTTP source fan-out, source metadata, missing-source policy handling.
- `internal/adapters/scriptorium`: subprocess render/run adapter.
- `internal/adapters/distributor`: distributor upload/status adapter.
- `internal/state`: filesystem state store, artifact paths, metadata, snapshots, notification artifacts.
- `internal/generatedtext` and `internal/reporttemplate`: generated-text schemas, validation, render contexts, embedded templates.
- `internal/promptinput`, `internal/changes`, `internal/fileutil`, and `internal/timeutil`.
- `examples/config.yml`.
- Package-level tests across the inspected packages.
Reviewed documentation and intended interfaces:
- `README.md`.
- `docs/policy/architecture.md`.
- `docs/policy/development.md`.
- `docs/policy/documentation.md`.
- `docs/config.md`.
- `docs/cli.md`.
- `docs/operations.md`.
- Relevant `docs/internal/` and `docs/integrations/` documents.
- Existing roadmap files under `docs/roadmap/` for intended future interface direction.
Important areas not inspected deeply:
- Generated local `workspace/` artifacts were not audited as source code. They are useful examples of output shape, but they are not authoritative implementation.
- No nonexistent package areas such as `internal/stage`, `internal/storage`, `internal/artifacts`, `internal/manifest`, or `pkg` were reviewed because this repository does not currently define them.
Commands used for lightweight validation and repository mapping:
```sh
go list ./...
rg -n "func validateGeneratedText|func buildRenderContext|var templates|var schemas|reportKind|reportIDForCommand|reportIDForConfigKey|normalizeReportModules|ReportModuleOverrides|func \(b \*bundleBuilder\) fetch" internal cmd docs examples
```
No full test suite was run because this is a report-only task.
## High-Confidence Deduplication Opportunities
### Generated-Text And Template Dispatch Are Scattered
Affected files/packages:
- `internal/report`
- `internal/app/app.go`
- `internal/generatedtext`
- `internal/reporttemplate/reporttemplate.go`
Duplicated or near-duplicated behavior:
- Report definitions carry `GenerationMode`, `PromptID`, `GeneratedTextSchemaID`, and `TemplateID`.
- `internal/reporttemplate` has separate `templates` and `schemas` maps keyed by string IDs.
- `internal/app` switches on `GeneratedTextSchemaID` in `validateGeneratedText`.
- `internal/app` switches on `TemplateID` in `buildRenderContext`.
- `internal/generatedtext` has report-specific render-context builders that must stay aligned with the schema/template IDs.
Why it matters:
- Adding a new generated-text report requires edits in several packages. A missed registration can produce runtime failures that are hard to catch until a report is generated.
- Template ID, schema ID, generated-text type, validator, and render-context builder are a single policy decision but currently have several partial catalogs.
Recommended refactor:
- Add a narrow generated-text report catalog, likely in `internal/generatedtext` or `internal/reporttemplate`.
- The catalog should map a schema/template ID to:
- validator function;
- render-context builder;
- embedded template ID/path;
- embedded schema ID/path.
- Keep `internal/report.Definition` authoritative for which generated-text assets a report uses.
- Have `internal/app` resolve the generated-text handler once instead of owning report-specific switches.
- Keep the catalog explicit. Do not introduce reflection-heavy registration or a plugin system.
Suggested tests:
- Add a completeness test proving every report with generated-text-template mode has a registered validator, schema, template, and render-context builder.
- Keep existing hourly and tomorrow generated-text workflow tests.
- Add a negative test for an unsupported generated-text schema/template ID with a clear error.
Risk level: Medium. The refactor touches central generation flow, but it can be done with narrow adapters and existing tests.
### Final Report Completion Logic Is Duplicated Across Generation Modes
Affected files/packages:
- `internal/app/app.go`
- `internal/state`
- `internal/adapters/distributor`
Duplicated or near-duplicated behavior:
- Direct Markdown generation and generated-text-template generation both need to:
- produce a managed report artifact;
- update metadata;
- save final metadata;
- optionally copy to `--out` or `--out-dir`;
- optionally notify distributor;
- propagate notification failures as report failures;
- return the same result fields.
- The generated-text path has additional generated-text and render-context artifacts, but the finalization policy is the same after a managed Markdown path exists.
Why it matters:
- Output copy, metadata save, notification artifact, and result behavior are user-facing. If finalization changes, the fix must be made in more than one branch.
- This creates drift risk as additional template-based reports are added.
Recommended refactor:
- Add a small unexported app helper such as `finalizeRenderedReport`.
- Inputs should include the resolved report, run ID, metadata, managed report path, optional output request, notification config, and current result fields.
- The helper should not own the full workflow. It should only perform the common finalization after a report file exists.
- Preserve existing artifact paths and CLI behavior.
Suggested tests:
- Existing single-report tests for managed report path, `--out`, and `--out-dir`.
- Generated-text report tests proving metadata and notification debug artifacts are saved.
- Batch tests proving one report finalization failure does not stop independent reports, while aggregate status is nonzero.
Risk level: Medium. The behavior is central, but the target helper is small and can be tested around existing workflows.
### Report Name, Command Name, Config Key, And Alias Resolution Are Split
Affected files/packages:
- `internal/cli/root.go`
- `internal/app/app.go`
- `internal/config/reports.go`
- `internal/report`
Duplicated or near-duplicated behavior:
- CLI command names are parsed in `internal/cli`.
- App request kinds are mapped to report IDs in `internal/app`.
- Config report keys and aliases are mapped to report IDs in `internal/config`.
- Report definitions, artifact groups, batch output names, prompt IDs, and generation modes live in `internal/report`.
Why it matters:
- Public names and internal IDs are release-sensitive. Drift could make a report available through the CLI but unavailable through config, or vice versa.
- The clean break from older report IDs makes this more important because names also affect distributor paths and public URLs.
Recommended refactor:
- Keep `internal/report` as the canonical home for report identity policy.
- Add explicit report-owned helpers for canonical command/config/batch name resolution, for example:
- `ReportIDForCommandName(name string)`;
- `ReportIDForConfigKey(key string)`;
- `BatchReportsForCommandName(name string)`.
- Keep compatibility aliases only where the project intentionally supports them.
- Let `internal/cli` remain responsible for parsing flags and producing app requests, but avoid duplicating report-name policy there.
Suggested tests:
- Table tests for all supported CLI generate report names.
- Table tests for config aliases and unknown keys.
- Batch command tests for scheduled/default batch membership.
- A registry consistency test proving each CLI-exposed report maps to a registered report definition.
Risk level: Low to medium. The behavior is simple but public-facing.
### Report Module Override Normalization Runs In More Than One Place
Affected files/packages:
- `internal/config/load.go`
- `internal/config/validate.go`
- `internal/config/reports.go`
Duplicated or near-duplicated behavior:
- `Load` calls `normalizeReportModules`.
- `Validate` also calls `normalizeReportModules`.
- `ReportModuleOverrides` assumes usable report keys but silently skips unknown keys if called on a manually constructed or unvalidated `Config`.
Why it matters:
- Normalization currently appears idempotent, but it mutates config and decodes module options. Running it from both load and validation increases the chance of side effects or inconsistent future behavior.
- Silent skipping in `ReportModuleOverrides` is safe on the normal `Load` path but less safe for tests or programmatic callers that construct `Config` directly.
Recommended refactor:
- Split mutation from validation:
- `Load` should apply defaults, file contents, CLI overrides, secret loading, and normalizing mutations once.
- `Validate` should validate normalized config or call a non-mutating report-module validation helper.
- Consider changing `ReportModuleOverrides` to return `(map[report.ID][]module.ConfigItem, error)` or making it unexported behind a validated config path.
- If retaining the current method signature, add a comment documenting that it expects a validated config and add tests around invalid manual config behavior.
Suggested tests:
- Config tests proving module option normalization happens once and produces the same typed options.
- Tests for duplicate report aliases and unknown report keys.
- App-level test with manually constructed invalid report module overrides if the app continues to accept raw `Config`.
Risk level: Low. This is a contained config cleanup.
### Weather API Source Fetching Repeats The Same Lifecycle
Affected files/packages:
- `internal/adapters/weatherapi/client.go`
- `internal/weatherdata`
- `internal/forecast`
Duplicated or near-duplicated behavior:
- Each source fetch method repeats endpoint construction, query option handling, `data:null` or malformed handling, decoding, source metadata construction, and bundle assignment.
- The repeated flow is visible in observation, current, hourly, narrative, alerts, discussion, weather story, and SPC convective outlook fetch functions.
Why it matters:
- Source behavior has important differences: hourly is required, alerts treat `data:null` as checked/no active alerts, weather story has a minimal query, and SPC uses a source-specific endpoint. Those differences are valid, but the common lifecycle is still easy to drift.
- New sources are likely. Repeating source metadata, warning, and malformed-data handling for every source increases maintenance risk.
Recommended refactor:
- Introduce a small source fetch helper or source spec for the common lifecycle.
- Keep source-specific decode and special cases explicit.
- Do not build a generic ingestion framework or reflection-based decoder.
- The helper should centralize:
- source key;
- endpoint;
- query options;
- required/optional behavior;
- null-data policy;
- metadata timestamp/hash extraction;
- warning/error conversion.
Suggested tests:
- Existing fixture-server tests for source count, warnings, and endpoint paths.
- Dedicated tests for required hourly `data:null`, alerts `data:null`, optional source missing behavior, weather story metadata, and SPC overlap behavior.
- A new test proving source metadata shape remains consistent across helper-driven sources.
Risk level: Medium. The adapter is well covered, but source semantics are not all identical.
## Medium-Confidence Opportunities
### Template Module Extraction Has Repeated Optional-Stanza Plumbing
Affected files/packages:
- `internal/generatedtext/render_context.go`
Duplicated or near-duplicated behavior:
- Hourly and tomorrow render contexts both extract many of the same module stanzas from the same `module.Snapshot`.
- The repeated `optionalStanza` calls carry the same module IDs and error pattern.
Why it matters:
- This is not a major behavior risk today, but additional generated-text-template reports will repeat the same extraction pattern.
- It also makes template context changes noisier than necessary.
Recommended refactor:
- Add a small unexported snapshot lookup/cache type in `internal/generatedtext`.
- It can provide typed methods such as `CurrentConditions()`, `HourlyForecast()`, and `PrecipTiming()`.
- Keep report-specific context structs. Do not flatten all template contexts into one generic map.
Suggested tests:
- Existing render-context tests.
- A test that omitted optional modules become nil pointers and extraction errors include the module ID.
Risk level: Low.
### Generated-Text Validators Share Strict JSON Mechanics
Affected files/packages:
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
Duplicated or near-duplicated behavior:
- Report-specific validators perform strict JSON decoding, unknown-field rejection, string trimming, required-field validation, and canonical JSON output.
Why it matters:
- The report-specific validation rules should remain separate, but strict decoding mechanics should be consistent across all generated-text schemas.
- A new generated-text report will likely copy the same parsing pattern.
Recommended refactor:
- Add a small internal helper for strict single-object JSON decode and canonical re-marshal.
- Keep report-specific structs and semantic validation in each report file.
Suggested tests:
- Existing generated-text tests for unknown fields, missing required fields, and canonical output.
- Add one shared-helper test only if the helper has nontrivial behavior.
Risk level: Low.
### Distributor Notification Template Values Are Split Between Config And App
Affected files/packages:
- `internal/config/notify_templates.go`
- `internal/app/app.go`
Duplicated or near-duplicated behavior:
- Config owns template validation and rendering helpers.
- App constructs the concrete template value map, including location ID, report ID, run ID, artifact group, batch output name, bundle ID, and valid-period variables.
Why it matters:
- This split is mostly appropriate because app has report/run context. However, the list of allowed variables and the list of populated variables must stay aligned.
- Future notifier integrations could duplicate the same valid-period value construction.
Recommended refactor:
- Keep rendering/validation in `internal/config`.
- Move value construction into a named app helper with focused tests, or introduce a small config-owned `DistributorTemplateValues` constructor if it can avoid importing app concepts.
- Do not expose distributor package types outside the adapter.
Suggested tests:
- Existing config template validation tests.
- App tests for rendered pipeline ID, bundle ID, idempotency key, and bundle paths for hourly/tomorrow/daily periods.
Risk level: Low.
### Test Setup Is Repeated Across App, CLI, And Adapter Tests
Affected files/packages:
- `internal/app/*_test.go`
- `internal/cli/*_test.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/adapters/distributor/*_test.go`
Duplicated or near-duplicated behavior:
- Tests repeatedly create temporary configs, fake Scriptorium behavior, fake Weather API servers, generated workspace assertions, and notification expectations.
Why it matters:
- Future cleanup around report generation will be safer with focused helpers.
- The current duplication is not severe enough to justify a global test framework.
Recommended refactor:
- Add package-local helpers where duplication is already present:
- app test config builder;
- app fake renderer/notifier setup;
- CLI command invocation helper;
- Weather API fixture server helper.
- Avoid cross-package test utility packages unless duplication becomes materially worse.
Suggested tests:
- No new behavior tests are needed solely for helper extraction.
- Run affected package tests after mechanical helper cleanup.
Risk level: Low.
## Boundary And Responsibility Concerns
The primary package boundaries are sound:
- `internal/config` owns configuration, defaults, YAML decoding, secret loading, and validation.
- `internal/report` owns report identity and valid-period policy.
- `internal/briefing` owns module output construction.
- `internal/facts` owns collected-to-derived fact preparation.
- `internal/app` owns orchestration.
- `internal/state` owns workspace artifact paths and persistence.
- `internal/adapters/*` own external system details.
Areas with boundary ambiguity:
- Generated-text report registration is currently shared across report definitions, app switches, generated-text validators, and reporttemplate maps. A generated-text catalog would make this responsibility clearer without changing the package architecture.
- Report name resolution partly belongs to `internal/report` but is currently repeated in CLI, app, and config. The report package is the better canonical home because names, aliases, IDs, artifact groups, and batch membership are report identity policy.
- App owns distributor template value construction while config owns template validation. This is acceptable, but the value set should be explicitly named and tested because it is part of the notifier contract.
No serious external-system leakage was found:
- Distributor package types are confined to `internal/adapters/distributor`.
- Scriptorium subprocess execution is confined to `internal/adapters/scriptorium`.
- Weather API HTTP details are confined to `internal/adapters/weatherapi`.
## Path, Key, And Naming Construction Review
Path and artifact construction is mostly centralized:
- `internal/state` owns workspace artifact path construction.
- `internal/report.Definition` owns artifact groups and batch output names.
- Distributor bundle paths are rendered from validated config templates.
- `internal/fileutil` centralizes atomic file writes and copies.
Areas needing cleanup:
- Report command/config aliases should be centralized in or near `internal/report`.
- Generated-text template/schema IDs should be resolved through one catalog instead of separate maps and app switches.
- Distributor template values should be constructed in one named helper and tested as a contract.
Areas that do not need cleanup now:
- Managed workspace path shape appears clear and centralized enough.
- There is no object-store key or manifest key layer in this repository.
- Optional output copy behavior is app-level user-interface behavior and does not need to move into state.
## Resolution And Catalog Review
Strong catalogs:
- Report registry in `internal/report`.
- Module registry in `internal/briefing`.
- Module IDs in `internal/module`.
- State artifact paths in `internal/state`.
Weaker catalogs:
- Generated-text validators, schemas, render contexts, and templates are related but not registered together.
- Report public names and aliases are split between CLI, app, and config.
- Weather API sources are implemented as explicit methods without a shared source catalog. This is acceptable today but will become noisier as more sources are added.
Recommended centralization:
- First centralize generated-text/template resolution.
- Then centralize report public-name resolution.
- Consider a small Weather API source spec only when the next source is added or when changing source warning/provenance behavior.
## Config And Command-Loading Review
Config loading is mostly consistent:
- Defaults are applied before file decoding.
- CLI overrides are applied before validation.
- Secrets directory loading happens before final validation that needs environment-backed secrets.
- Validation is centralized in `internal/config`.
- CLI commands use standard-library parsing as intended.
Differences that appear intentional:
- `--out` and `--out-dir` are request-level output controls, not config defaults.
- Distributor token values come from environment/secrets rather than raw config.
- Inspect commands load config because workspace and notification-related state depend on config.
Likely accidental or cleanup-worthy differences:
- Report module normalization happens in both `Load` and `Validate`.
- `ReportModuleOverrides` silently ignores unknown report keys if it receives unvalidated config.
- Report name resolution is split across CLI/app/config instead of using report-owned name policy.
## State, Manifest, Or Progress Handling Review
The repository does not currently implement a manifest, checkpoint, resume, or progress subsystem. State is artifact-oriented:
- metadata;
- briefing snapshots;
- module snapshots;
- prompt/data packages;
- preflight output;
- generated text;
- render contexts;
- rendered reports;
- notification artifacts.
This is consistent with the current application size. I do not recommend adding a manifest/resume system before the next release.
Potential future cleanup:
- `LoadMetadataByRunID` currently discovers reports by scanning existing metadata through `ListReports`. This is acceptable for the current workspace size. If workspaces become large or inspect commands need to be faster, add a narrow run-index artifact or direct path helper then.
## Refactors To Avoid
Avoid these before the next release:
- A generic workflow engine for report generation.
- A plugin architecture for reports, modules, adapters, or notifiers.
- A Cobra migration.
- Per-module or per-report Go packages.
- A broad manifest/resume/progress subsystem.
- Reflection-heavy Weather API ingestion.
- A global test helper framework shared across all packages.
- Consolidating all adapter error handling into one generic wrapper.
- Replacing explicit report/module definitions with config-only definitions.
- Rewriting state storage around generated workspace scanning.
These would add abstraction before the implementation has enough repeated complexity to justify it.
## Recommended Implementation Sequence
1. Centralize generated-text/template catalog resolution.
- Goal: one explicit catalog for validator, schema, template, and render-context builder.
- Files: `internal/generatedtext`, `internal/reporttemplate`, `internal/app`, tests.
- Validation: generated-text package tests, reporttemplate tests, app workflow tests.
2. Extract final report finalization helper.
- Goal: one app helper for metadata save, output copy, notification, and result population after a managed Markdown report exists.
- Files: `internal/app`, app tests.
- Validation: app tests for direct Markdown and generated-text-template reports.
3. Centralize report public-name and alias resolution.
- Goal: make `internal/report` the canonical home for command/config/batch name mapping.
- Files: `internal/report`, `internal/cli`, `internal/app`, `internal/config`.
- Validation: CLI parser tests, config tests, report registry tests.
4. Split config report-module normalization from validation.
- Goal: avoid duplicate mutation and make invalid manual config behavior explicit.
- Files: `internal/config`, app config-loading tests.
- Validation: config tests and app tests using report module overrides.
5. Add a small generated-text snapshot lookup helper.
- Goal: reduce repeated optional-stanza extraction while preserving report-specific context structs.
- Files: `internal/generatedtext`.
- Validation: render-context tests.
6. Add a shared strict JSON helper for generated-text validators.
- Goal: keep JSON validation mechanics consistent without merging report semantics.
- Files: `internal/generatedtext`.
- Validation: generated-text validation tests.
7. Consider Weather API source lifecycle helper.
- Goal: centralize source metadata, null-data, warning, and decode lifecycle only if the helper remains explicit.
- Files: `internal/adapters/weatherapi`, adapter tests.
- Validation: Weather API fixture and missing-source policy tests.
8. Add package-local test helpers.
- Goal: reduce noisy repeated setup in app, CLI, and adapter tests.
- Files: test files only.
- Validation: affected package tests and full `go test ./...`.
9. Dead-code and stale-symbol sweep.
- Goal: remove any old aliases, unused helpers, or stale docs exposed by prior cleanup.
- Files: repository-wide as needed.
- Validation: `rg` stale-symbol checks, `go test ./...`, `git diff --check`.
## Test Strategy
Tests to add before refactoring:
- Generated-text catalog completeness test for all generated-text-template reports.
- Report public-name mapping tests for CLI names, config keys, aliases, and batch names.
- Config tests around duplicate/unknown report module overrides and normalized module options.
Tests to add during refactoring:
- Finalization helper tests through app workflows rather than direct private-helper tests.
- Generated-text validator tests for shared strict JSON behavior.
- Render-context tests proving omitted optional modules remain nil.
- Weather API source lifecycle tests if a source helper is introduced.
Focused commands for cleanup work:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./internal/report ./internal/cli ./internal/config
go test ./internal/adapters/weatherapi
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Manual checks:
- Confirm public CLI syntax and report IDs remain stable unless intentionally changed.
- Confirm managed workspace paths and distributor bundle paths remain stable.
- Confirm no secret values appear in errors, metadata, debug artifacts, tests, or docs.
- Confirm non-roadmap documentation describes only implemented behavior.
## Appendix: Findings Not Worth Acting On
### Standard-Library CLI Flag Parsing Has Some Repetition
The CLI repeats small flag parsing blocks, but the commands have different argument shapes and error messages. A broad command framework would obscure behavior more than it would simplify the code. Keep the standard-library parser.
### Module Files Are Numerous But Clear
One module per focused file in `internal/briefing` is appropriate. Do not collapse modules into a generic map-based engine or split them into per-module packages.
### Report Files Are Explicit By Design
One report or report family per file in `internal/report` is a good navigation pattern. Do not replace these definitions with config-only report declarations before the report set stabilizes.
### Adapter Error Handling Should Remain Local
Scriptorium, Weather API, and distributor failures have different semantics. Shared error-wrapping helpers would likely erase useful context. Keep adapter-local error handling unless exact duplication becomes obvious.
### State Scanning Is Acceptable At Current Scale
The current metadata scan approach is simple and inspectable. A manifest or run index should wait until workspace size or performance makes it necessary.
### Prompt/Data Package Category Naming Is Presentation Policy
The module output category names are prompt-facing schema policy. They should remain explicit in the prompt-input/module layer rather than being generalized into a taxonomy engine.

View File

@@ -1,618 +0,0 @@
# Cleanup Roadmap
## Purpose
This roadmap defines the staged cleanup work recommended by `docs/roadmap/audit.md`. It is written for an LLM coding agent that will implement the stages in order.
The cleanup is intended to simplify, centralize, and clarify implementation logic before the next major release without changing public CLI syntax, managed workspace paths, report IDs, prompt IDs, generated output semantics, or external integration behavior unless a stage explicitly says otherwise.
This file belongs under `docs/roadmap/` because it describes planned refactoring work. Non-roadmap documentation must not describe the planned behavior here until the corresponding code is implemented.
## Cleanup Principles
- Preserve implemented behavior unless a stage explicitly directs a change.
- Prefer narrow explicit helpers and registries over broad frameworks.
- Keep package boundaries from `docs/policy/architecture.md`:
- `internal/config` owns config loading, defaults, overrides, secrets, and validation.
- `internal/report` owns report identity, report definitions, valid periods, batches, output names, and comparison declarations.
- `internal/cli` owns command parsing and help text.
- `internal/app` owns orchestration.
- `internal/briefing` owns prompt-facing module builders and module registry.
- `internal/generatedtext` owns generated-text validation and render-context construction.
- `internal/reporttemplate` owns embedded templates and generated-text schema assets.
- external system types stay inside `internal/adapters/*`.
- Do not introduce Cobra, a workflow engine, plugin architecture, per-module packages, per-report packages, a manifest/resume system, or a global test framework.
- Keep tests focused near the package that owns the behavior.
- Run `gofmt -w` on changed Go files.
- Update implemented docs only in the stage that changes implemented behavior or package contracts.
## Decisions Locked
- Generated-text/template resolution should use one explicit catalog rather than app-owned switches plus separate template/schema maps.
- Report definitions remain authoritative for report IDs, prompt IDs, generation mode, template ID, generated-text schema ID, valid-period resolver, module composition, artifact group, and output naming.
- `internal/report` should become the canonical home for report public-name and alias policy used by CLI, config, and app request resolution.
- App orchestration should keep a direct, readable workflow. Extract only narrow helpers for repeated policies such as final report completion.
- Config report-module normalization should be a load-time mutation. Validation should not perform duplicate mutating normalization.
- `ReportModuleOverrides` should not silently ignore invalid report keys in any path an application caller can reach.
- Weather API source lifecycle cleanup should be explicit and source-aware. Do not use reflection or a generic ingestion framework.
- Test cleanup should use package-local helpers only.
- State remains artifact-oriented. Do not add a manifest, checkpoint, resume, or run-index subsystem in this cleanup sequence.
## Stage 1: Generated-Text And Template Catalog
Goal: make generated-text report asset resolution one explicit catalog so validators, schemas, templates, and render-context builders cannot drift independently.
### Scope
Create a narrow catalog for generated-text-template reports. The catalog should connect:
- generated-text schema ID;
- embedded JSON schema asset;
- generated-text validator;
- report template ID;
- embedded Markdown template asset;
- render-context builder.
### Implementation Guidance
- Prefer placing the primary catalog in `internal/generatedtext` if it owns validator and render-context function wiring.
- Keep embedded asset reading in `internal/reporttemplate`; do not move embedded template or schema files.
- Add an exported or package-internal lookup API with a small app-facing surface, for example:
- `generatedtext.LookupDefinition(def report.Definition)`;
- or `generatedtext.Lookup(schemaID, templateID string)`.
- The returned handler should expose methods or fields for:
- `Validate(data []byte) (any, []byte, error)`;
- `BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, facts app/report facts input, generated any) (any, error)`;
- schema/template IDs needed by `internal/reporttemplate`.
- Avoid importing `internal/app` into `internal/generatedtext`. If app-owned `ReportFacts` is currently needed, pass its constituent `facts.CollectedFacts` and `facts.DerivedFacts`.
- Remove `validateGeneratedText` and `buildRenderContext` switches from `internal/app` after the catalog owns this dispatch.
- Keep `internal/reporttemplate.Template`, `Schema`, and `Render` as small asset helpers unless the catalog cleanly replaces their maps.
- If `reporttemplate` keeps template/schema maps, add tests tying those maps to the generated-text catalog. Prefer one source of truth if this is straightforward.
### Files To Inspect
- `internal/app/app.go`
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
- `internal/generatedtext/render_context.go`
- `internal/reporttemplate/reporttemplate.go`
- `internal/report/*_report.go`
- `internal/report/definition.go`
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
### Acceptance Criteria
- Adding a new generated-text-template report requires registering one generated-text catalog entry and adding the report definition/assets, not editing app-level switches.
- `internal/app` no longer switches on concrete generated-text schema IDs or template IDs.
- Unsupported schema/template combinations return actionable errors that include the report ID and the unsupported ID.
- Existing hourly and tomorrow report behavior is unchanged.
- No generated-text schema, template, prompt ID, or report ID changes are introduced.
### Tests
Add or update:
- Generated-text catalog completeness test proving every report with `GenerationGeneratedTextTemplate` has:
- a generated-text catalog entry;
- an embedded schema asset;
- an embedded template asset;
- a validator;
- a render-context builder.
- Negative test for unsupported generated-text schema/template IDs.
- Existing hourly and tomorrow validation tests.
- Existing reporttemplate render tests.
- App workflow tests for hourly and tomorrow reports.
Validation commands:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/report ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 2: Final Report Finalization Helper
Goal: centralize repeated metadata, output copy, notification, and result-population logic after a managed Markdown report exists.
### Scope
Extract a narrow app helper that runs after either generation mode has produced the managed Markdown report file. This helper should not own Weather API fetching, facts, modules, Scriptorium calls, generated-text validation, or template rendering.
### Implementation Guidance
- Add an unexported helper in `internal/app`, for example `finalizeRenderedReport`.
- Inputs should include the values already available in generation flow:
- context;
- loaded config;
- store;
- resolved report;
- RunID;
- metadata;
- managed report path;
- optional output path/output directory request;
- report result being assembled, or enough fields to return one.
- The helper should centralize:
- optional `--out` copy;
- optional `--out-dir` copy;
- final metadata updates related to report paths/output copy/notification;
- final metadata save;
- distributor notification invocation when enabled;
- notification debug artifact save behavior already implemented;
- notification failure propagation.
- Preserve current ordering:
- managed report is written before finalization;
- metadata is saved after final report path is known;
- notification happens only after successful report generation and final metadata save.
- Preserve current behavior that distributor uses the managed report path, never optional output copies.
- Keep notification request construction in app unless a later stage creates a smaller named helper for template values.
### Files To Inspect
- `internal/app/app.go`
- `internal/state/metadata.go`
- `internal/state/filesystem.go`
- `internal/adapters/distributor`
- `internal/fileutil`
- app tests covering generated reports, output copies, and notification artifacts.
### Acceptance Criteria
- Direct Markdown report generation and generated-text-template generation both use the same finalization helper.
- Managed report paths, optional output copy behavior, metadata JSON shape, notification artifacts, and batch behavior are unchanged.
- Single-report notification failure still returns an error.
- Batch generation still continues independent reports and returns aggregate failure when any report fails.
- The helper is small enough to read without becoming a workflow engine.
### Tests
Add or update:
- App test proving direct Markdown and generated-text-template reports both save final metadata consistently.
- App test proving `--out` and `--out-dir` still copy from the managed report path.
- App test proving notification receives the managed report path.
- App test proving notification failure fails a single report.
- Batch test proving notification failure marks only that report failed and the batch exits nonzero.
Validation commands:
```sh
go test ./internal/app ./internal/state ./internal/adapters/distributor
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 3: Report Public Name And Alias Resolution
Goal: make `internal/report` the canonical source for public report names, config keys, aliases, and batch command membership.
### Scope
Move report-name resolution policy out of scattered CLI/app/config helpers and into report-owned helpers.
### Implementation Guidance
- Add report-owned helpers in `internal/report`, for example:
- `IDForCommandName(name string) (ID, error)`;
- `IDForConfigKey(key string) (ID, error)`;
- `BatchForCommandName(name string) ([]ID, error)`;
- `CommandNames() []string` if helpful for CLI help tests.
- Preserve current accepted names and aliases:
- CLI report names currently accepted by `generate`;
- config aliases currently accepted by `reports.*`;
- batch names currently accepted by `run`.
- Keep user-facing error messages concise and actionable. It is acceptable for exact wording to change if tests are updated and the error remains clear.
- Update `internal/cli` to use report-owned command-name resolution or to call app request helpers that use it.
- Update `internal/app` to remove private `reportIDForCommand` and `reportBatchForCommand` mappings if report-owned helpers can replace them.
- Update `internal/config` to remove private `reportIDForConfigKey` in favor of report-owned config-key resolution.
- Avoid creating new exported app request types solely for this cleanup.
### Files To Inspect
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/period.go`
- `internal/report/*_report.go`
- `internal/cli/root.go`
- `internal/app/app.go`
- `internal/config/reports.go`
- `docs/cli.md`
- `docs/config.md`
### Acceptance Criteria
- Report identity, command name, config key, and batch membership policy are discoverable from `internal/report`.
- CLI/app/config no longer maintain independent report ID switch statements for the same names.
- All existing public command names and config aliases continue to work unless explicitly documented as removed in this stage. This cleanup should not remove aliases.
- Distributor path variables that use report ID or artifact group are unchanged.
### Tests
Add or update:
- Report tests for command-name-to-ID mapping.
- Report tests for config-key-to-ID mapping and aliases.
- Report tests for batch names and report order.
- CLI parser tests for every supported `generate` report name.
- Config tests for report override aliases and unknown keys.
- App tests for run batch command mapping if not covered through CLI.
Validation commands:
```sh
go test ./internal/report ./internal/cli ./internal/config ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 4: Config Report Module Normalization
Goal: remove duplicate mutating normalization from config validation and make report module override extraction explicit and safe.
### Scope
Separate load-time config mutation from validation. Ensure report module overrides cannot silently ignore invalid report keys in app-facing paths.
### Implementation Guidance
- Keep configuration loading order:
1. built-in defaults;
2. config file;
3. CLI overrides;
4. report module normalization;
5. secrets directory loading;
6. validation.
- `Load` should continue returning a fully normalized and validated `Config`.
- Refactor `Validate` so it does not perform duplicate mutating normalization.
- Options:
- Preferred: split `normalizeReportModules` into a mutating load-time normalizer and a non-mutating validator used by `Validate`.
- Acceptable: make `Validate` require normalized config and document/test that public callers should use `Load` for full processing.
- Do not remove `config.Validate` unless all callers and tests can clearly use `Load`.
- Update `ReportModuleOverrides` so invalid report keys are not silently ignored in app paths.
- Preferred: change it to `ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error)` and update callers.
- Acceptable: keep the signature only if `Config` gains an internal validated/normalized marker and the method clearly cannot be reached with invalid keys.
- Preserve module option strict YAML decoding and composition validation.
- Preserve current config file syntax.
### Files To Inspect
- `internal/config/load.go`
- `internal/config/validate.go`
- `internal/config/reports.go`
- `internal/config/config.go`
- `internal/config/config_test.go`
- `internal/app/app.go`
- `examples/config.yml`
- `docs/config.md`
### Acceptance Criteria
- Report module normalization happens once on the normal `Load` path.
- Validation no longer performs duplicate mutating normalization.
- App report registry construction handles report override errors explicitly.
- Unknown report keys in report module overrides cannot be silently skipped.
- Existing valid example config still loads.
- Existing CLI overrides still take precedence over config and defaults.
### Tests
Add or update:
- Config tests for normal load with typed module options.
- Config tests for direct validation of duplicate aliases and unknown report keys.
- Config tests proving `ReportModuleOverrides` returns or surfaces an error for invalid keys if called on invalid config.
- App test proving report registry construction fails clearly on invalid overrides when supplied programmatically.
- Existing example config load test.
Validation commands:
```sh
go test ./internal/config ./internal/app ./internal/cli
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 5: Generated-Text Internal Helper Cleanup
Goal: reduce low-risk duplication inside `internal/generatedtext` after the catalog is in place.
### Scope
Add small helpers for repeated snapshot stanza extraction and strict JSON validation mechanics while preserving report-specific context structs and validation rules.
### Implementation Guidance
Snapshot lookup:
- Add an unexported snapshot lookup/cache type, for example `moduleSnapshotLookup`.
- It should wrap `module.Snapshot` and provide typed methods for repeated module stanzas.
- Keep report-specific context structs:
- `HourlyRenderContext`;
- `TomorrowRenderContext`;
- future report contexts.
- Do not replace typed context structs with a generic `map[string]any`.
- Preserve nil pointer behavior for omitted optional modules.
Strict JSON helper:
- Add a small helper for:
- single JSON object decode;
- `DisallowUnknownFields`;
- detection of trailing JSON tokens;
- canonical re-marshal of validated output.
- Keep report-specific required-field checks in `hourly.go`, `tomorrow.go`, and future report validators.
- Keep generated-text schema files unchanged unless tests show they are out of sync with validator behavior.
### Files To Inspect
- `internal/generatedtext/render_context.go`
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
- `internal/generatedtext/*_test.go`
- `internal/module`
- `internal/briefing`
### Acceptance Criteria
- Repeated optional stanza extraction is reduced without changing render-context JSON shape.
- Strict JSON decode behavior remains the same for hourly and tomorrow generated text.
- Unknown fields, missing required fields, trailing JSON, and invalid JSON errors remain actionable.
- Existing templates render unchanged.
### Tests
Add or update:
- Generated-text validation tests for unknown fields, trailing JSON, missing required fields, and canonical output.
- Render-context tests proving key module pointers are populated when present and nil when omitted.
- Render-context tests proving extraction errors name the module ID.
Validation commands:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 6: Weather API Source Lifecycle Helper
Goal: reduce repeated Weather API source fetch boilerplate while keeping source-specific semantics explicit.
### Scope
Introduce a small helper or source spec for shared source lifecycle behavior in `internal/adapters/weatherapi`.
### Implementation Guidance
- Centralize only common lifecycle mechanics:
- endpoint and query construction;
- envelope fetch;
- absent/malformed/null data handling according to source policy;
- source metadata construction;
- warning/error conversion;
- source hash handling.
- Keep source-specific decode functions explicit.
- Preserve these source-specific behaviors:
- hourly forecast remains required for normal report generation;
- alerts `data:null` means checked successfully with no active alerts;
- optional non-alert source `data:null` follows missing-source policy;
- weather story uses its current endpoint/query behavior;
- SPC convective outlooks use the current endpoint constant and overlap filtering downstream;
- current source warning and provenance JSON shapes remain unchanged.
- Do not use reflection, generics-heavy helpers, or a broad source framework.
- If the helper makes the code less readable, stop and keep the explicit source functions.
### Files To Inspect
- `internal/adapters/weatherapi/client.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/weatherdata`
- `internal/forecast`
- `docs/integrations/weatherapi.md`
- `docs/internal/weather-data.md`
### Acceptance Criteria
- Source fetch functions are shorter but still readable and source-aware.
- Existing source warning behavior is unchanged.
- Existing source metadata fields and hashes are unchanged.
- No Weather API transport details leak outside `internal/adapters/weatherapi`.
- No prompt/data package schema changes are introduced.
### Tests
Add or update:
- Fixture-server test proving all expected endpoints are still requested.
- Required hourly `data:null` failure test.
- Alerts `data:null` no-active-alerts test.
- Optional non-alert missing-source policy tests for `error`, `warn`, and `none` where currently covered.
- Weather story metadata test.
- SPC source test if current coverage depends on source count or warning count.
Validation commands:
```sh
go test ./internal/adapters/weatherapi ./internal/weatherdata ./internal/forecast
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt, but it should be skipped if the implementing agent cannot keep the helper narrow and explicit.
## Stage 7: Package-Local Test Helper Cleanup
Goal: reduce noisy repeated test setup without creating a cross-package test framework.
### Scope
Add package-local helpers only where tests already repeat substantial setup.
### Implementation Guidance
- In `internal/app` tests, consider helpers for:
- temporary config/workspace construction;
- fake renderer setup;
- fake notifier setup;
- Weather API test server setup;
- artifact path/glob assertions.
- In `internal/cli` tests, consider a command invocation helper that captures stdout/stderr and exit status.
- In `internal/adapters/weatherapi` tests, keep fixture server helpers local to the adapter package.
- In `internal/adapters/distributor` tests, keep fake upload/status clients local to the adapter package.
- Do not create `internal/testutil` or a global test helper package in this stage.
- Do not change test behavior or remove meaningful assertions while reducing setup.
### Files To Inspect
- `internal/app/*_test.go`
- `internal/cli/*_test.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/adapters/distributor/*_test.go`
- `internal/generatedtext/*_test.go`
- `internal/reporttemplate/*_test.go`
### Acceptance Criteria
- Test setup duplication is reduced in packages that have obvious repetition.
- Tests remain readable without hiding important workflow details.
- No production code changes are required for this stage unless an existing test-only seam is missing and justified.
- No global test helper package is introduced.
### Tests
This stage changes tests only unless a small test seam is needed. Run:
```sh
go test ./internal/app ./internal/cli ./internal/adapters/weatherapi ./internal/adapters/distributor
go test ./...
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 8: Documentation And Dead-Code Sweep
Goal: align implemented documentation with cleanup changes and remove stale symbols left behind by prior stages.
### Scope
Update docs only for implemented cleanup changes. Remove stale code, stale tests, and stale roadmap references that no longer describe future work.
### Implementation Guidance
- Update non-roadmap docs only where package contracts or contributor workflow changed:
- `docs/policy/development.md` if package responsibilities or validation commands changed;
- `docs/internal/app-orchestration.md` if finalization ordering is documented;
- `docs/internal/report-registry.md` if report name/catalog helpers are documented;
- `docs/internal/generated-text.md` or equivalent if generated-text catalog behavior is documented;
- `docs/internal/weather-data.md` if Weather API source lifecycle behavior changed.
- Do not document deferred or unimplemented cleanup outside `docs/roadmap/`.
- Keep `docs/roadmap/future.md` for deferred ideas only if they remain future work.
- Remove stale private helpers after all call sites are gone.
- Run stale-symbol searches for removed helpers and old dispatch names.
### Files To Inspect
- `docs/policy/development.md`
- `docs/internal/`
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/roadmap/`
- production code touched by earlier stages
### Acceptance Criteria
- Non-roadmap docs describe implemented behavior only.
- No stale references remain to removed private app switches or old helper names.
- Roadmap docs do not duplicate implemented docs except to identify deferred future work.
- `go test ./...`, CLI help, and whitespace checks pass.
### Suggested Stale-Symbol Checks
Adjust names to match the actual implementation:
```sh
rg -n "validateGeneratedText|buildRenderContext|reportIDForCommand|reportBatchForCommand|reportIDForConfigKey" internal docs examples
rg -n "ReportModuleOverrides" internal docs examples
```
Validation commands:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Deferred Refactors
Do not include these in the cleanup sequence:
- Generic workflow engine.
- Plugin architecture.
- Cobra migration.
- Per-module or per-report Go packages.
- Manifest, checkpoint, resume, progress, or run-index system.
- Reflection-heavy Weather API ingestion framework.
- Global test helper package.
- Generic adapter error wrapper.
- Config-only report definitions.
- Broad state-storage redesign around workspace scanning.
These may be revisited only if concrete new requirements make the current explicit structure materially expensive.
## Global Validation Checklist
Run after each implementation stage unless the stage documents a narrower test set:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Additional checks after the full cleanup sequence:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./internal/report ./internal/cli ./internal/config
go test ./internal/adapters/weatherapi ./internal/adapters/distributor ./internal/adapters/scriptorium
go test ./internal/briefing ./internal/module ./internal/facts ./internal/forecast ./internal/promptinput ./internal/changes ./internal/state
rg -n "validateGeneratedText|buildRenderContext|reportIDForCommand|reportBatchForCommand|reportIDForConfigKey" internal docs examples
```
Manual review items:
- Public CLI syntax remains stable.
- Managed workspace paths remain stable.
- Distributor bundle paths remain stable.
- Report IDs, prompt IDs, template IDs, and generated-text schema IDs remain stable unless a stage explicitly changed them.
- No secret values appear in errors, logs, metadata, notification artifacts, docs, examples, or tests.
- Non-roadmap documentation describes only implemented behavior.
## Open Questions
No open questions block implementation of this cleanup roadmap.
The only discretionary item is Stage 6. Recommended approach: implement a narrow Weather API source lifecycle helper only if it remains explicit and improves readability. Viable alternative: skip Stage 6 and keep the current source-specific methods until the next Weather API source is added. The alternative is acceptable because current source functions are readable and tested, and the risk is mostly future maintenance cost rather than current behavior drift.

View File

@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
## Automatic Storm Monitoring
Manual Storm Report generation is implemented through
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Manual Storm Report generation is available through:
Proposed direction:
```sh
weatherreporter generate storm --start TIME --end TIME
```
1. detect candidate events deterministically from alerts, forecast discussion,
weather 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;
5. suppress ordinary low-impact thunder or rain chances.
Automatic storm-event evaluation is not implemented.
Possible direction:
1. Detect candidate storm events from alerts, forecast discussion, weather
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.
5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states:
@@ -27,41 +31,41 @@ Possible lifecycle states:
- `deescalating`
- `resolved`
Acceptance criteria before implementation:
Before implementation, the design must preserve scheduled report behavior,
manual Storm Report generation, inspectable evaluator failures, and fixture
coverage for deterministic candidate detection.
- scheduled reports and manual Storm Reports remain stable;
- candidate detection has fixture coverage;
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Future Report Types And Modules
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
## Future Report Types
Possible future report types:
- another short-fuse planning report distinct from the implemented Hourly
Report, if a separate product is needed;
- event-specific reports with stable event IDs;
- storm review or yesterday-style reports using historical observations;
- a short-fuse planning report distinct from the implemented Hourly Report, if
a separate product is needed
- event-specific reports with stable event IDs
- storm review or yesterday-style reports using historical observations
- archive-focused report variants if generated report history becomes a
first-class product.
first-class product
New reports should keep report identity, prompt IDs, templates, valid-period
resolution, artifact grouping, batch output names, and comparison policy inside
`internal/report`.
## Future Modules
Possible future modules:
- `hourly_table` for compact valid-period hourly facts;
- `hourly_table` for compact valid-period hourly facts
- `forecast_delta` if a separate stanza is useful beyond current Recent
Changes;
Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza;
deterministic stanza
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
prompt-facing storm-window module;
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
more useful than `area_forecast_discussion.options.sections`;
more useful than `area_forecast_discussion.options.sections`
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
upstream sources and report requirements exist.
upstream sources and report requirements exist
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is
@@ -69,79 +73,61 @@ represented in `CollectedFacts`.
Future module work should preserve these boundaries:
- collect upstream facts once per report run;
- keep upstream fetching out of modules;
- keep broad reusable calculations in `DerivedFacts`;
- keep prompt-facing field shape inside module builders;
- use typed options for configurable module behavior;
- keep module snapshots structured and deterministic for Recent Changes.
- collect upstream facts once per report run
- keep upstream fetching out of modules
- keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior
- keep module snapshots structured and deterministic for Recent Changes
## Distributor Notification Enhancements
Distributor notification currently uploads one managed Markdown report per
successful generated report through the configured HTTP upload pipeline.
Distributor notification uploads one managed Markdown report per successful
generated report through the configured HTTP upload pipeline. The following
enhancements are not implemented:
These enhancements are not current behavior:
- `failure_policy: warn`
- uploading metadata, module snapshots, data packages, or preflight artifacts
- durable upload retry queues
- distributor-specific CLI flags
- distributor workspace scanning
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
layout inside weatherreporter
- `failure_policy: warn`;
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;
- handling destination routing, Markdown-to-HTML transformation, public URLs,
or nginx layout inside weatherreporter.
Any distributor enhancement should preserve the existing adapter boundary:
Any distributor enhancement should preserve the adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations
These ideas are not current behavior:
These ideas are not implemented:
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system;
- dynamic module loading;
- user-defined module code;
- YAML-defined module schemas;
- module-owned Weather API fetching.
- native LLM client inside `weatherreporter`
- database-backed state
- public HTTP API
- multi-location selection
- daemon mode
- multi-user authorization
- plugin system
- dynamic module loading
- user-defined module code
- YAML-defined module schemas
- module-owned Weather API fetching
Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior.
## Cleanup Refactors
## Deferred Refactors
The initial cleanup pass intentionally left these refactors out because the
current implementation does not yet make them worth the added abstraction.
These refactors should remain deferred until new requirements or recurring
maintenance costs make the added abstraction worthwhile:
Revisit these only when new source types, report types, operational
requirements, or recurring maintenance costs make the duplication materially
more expensive:
- Weather API optional-source specification/helper refactor: consider when
additional Weather API sources make per-source fan-out, policy handling, and
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
- broad briefing weather-signal consolidation
- generic workflow engine
- Cobra migration
- manifest, resume, or progress system
- global test helper package
- logging subsystem
Any future implementation should preserve the existing public CLI, artifact
paths, report identities, module boundaries, and adapter boundaries unless a

View File

@@ -1,441 +0,0 @@
# Tomorrow Report Implementation Roadmap
## Purpose
This roadmap defines the staged implementation plan for
`docs/roadmap/tomorrow.md`. It is written for an LLM coding agent that will
implement each stage in order. The conceptual target state, user intent, and
locked product decisions live in `docs/roadmap/tomorrow.md`; this file defines
the concrete implementation sequence.
This is future-work planning. Do not treat the behavior described here as
implemented until the corresponding code, tests, examples, and non-roadmap docs
are updated.
## Implementation Guardrails
- Preserve public CLI syntax: `weatherreporter generate tomorrow`.
- Make a clean pre-release break from report ID `daily_tomorrow`; do not add
compatibility aliases.
- Keep report identity, prompt IDs, template IDs, artifact groups, output names,
and comparison policy centralized in `internal/report`.
- Reuse the existing `generated_text_template` workflow implemented for Hourly.
- Keep Scriptorium details behind the existing adapter boundary.
- Keep Go responsible for deterministic facts, valid periods, module snapshots,
structured generated-text validation, and final Markdown template rendering.
- Keep templates responsible for wording and layout.
- Keep generated JSON schemas and Markdown templates as embedded asset files,
not inline Go strings.
- Preserve current managed artifact behavior except where report identity
intentionally changes from `daily_tomorrow` to `tomorrow`.
## Stage 1: Report Identity Split
Goal: make Tomorrow an independent report ID and artifact identity while
preserving the public `generate tomorrow` command.
Implementation:
- Replace `report.DailyTomorrow` with `report.Tomorrow` whose value is
`"tomorrow"`.
- Rename report-definition helpers and resolvers around the new identity:
`dailyTomorrowDefinition` to `tomorrowDefinition`,
`dailyTomorrowModules` to `tomorrowModules`, and
`resolveDailyTomorrow` to `resolveTomorrow`.
- Update `report.DefaultRegistry`, `Registry.All`, batch resolution, and tests
so the built-in report order contains `tomorrow` instead of
`daily_tomorrow`.
- Update `internal/app` so `app.ReportTomorrow` resolves to `report.Tomorrow`.
- Keep the valid period as the next local civil day.
- Keep evening batch behavior: `run evening` should still generate the Tomorrow
report.
- Change Tomorrow definition identity fields to:
- `ID: report.Tomorrow`
- `Name: "Tomorrow Report"`
- `ArtifactGroup: "tomorrow"`
- `BatchOutputName: "tomorrow.md"`
- `Generated: true`
- `CompatiblePriorIDs: []report.ID{report.Tomorrow}`
- `ComparisonStrategy: report.CompareSameValidDate`
- Keep the current Tomorrow module composition initially, renamed to
`tomorrowModules`, so the prompt data package continues to include the
daypart, precipitation, alert, SPC, AFD, weather story, tomorrow planning,
and hourly facts already available.
- Update all code and tests that assert `daily_tomorrow` paths, metadata,
RunIDs, prior compatibility, or registry IDs.
Acceptance criteria:
- No production-code references to `report.DailyTomorrow` or report ID
`daily_tomorrow` remain.
- `weatherreporter generate tomorrow` still parses and resolves successfully.
- Evening batch contains `tomorrow`.
- Managed workspace artifact paths and distributor identity values now use
`tomorrow`.
Suggested validation:
```bash
go test ./internal/report ./internal/app ./internal/cli ./internal/state
```
This stage is suitable for one implementation prompt.
## Stage 2: Tomorrow GeneratedText Contract
Goal: add structured Tomorrow LLM output validation and schema assets.
Implementation:
- Add `internal/generatedtext/tomorrow.go` with:
```go
type Tomorrow struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
```
- Add `generatedtext.ValidateTomorrow`.
- Use `json.Decoder.DisallowUnknownFields`.
- Reject multiple JSON values.
- Trim `Summary`, `PrecipitationTiming`, `Confidence`, and each
`ForecastDiscussion` paragraph.
- Drop blank discussion paragraphs after trimming, then require at least one
remaining paragraph.
- Reject blank `Summary`.
- Return normalized JSON with the same public field names.
- Add `internal/reporttemplate/schemas/tomorrow.generated_text.schema.json`.
The schema should:
- require `summary`;
- require `forecast_discussion`;
- define `forecast_discussion` as an array of strings with at least one item;
- allow optional `precipitation_timing` and `confidence`;
- reject additional properties.
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
maintained Scriptorium prompt source asset. This file is a source contract for
out-of-band Scriptorium prompt registration; weatherreporter does not need to
load prompt Markdown at runtime.
- Add Tomorrow to `internal/reporttemplate` schema lookup.
- Update app generated-text validation dispatch so it can return either
`generatedtext.Hourly` or `generatedtext.Tomorrow`. Prefer a small generic
dispatch shape such as:
```go
func validateGeneratedText(def report.Definition, data []byte) (any, []byte, error)
```
Then type-check the returned value in render-context dispatch.
Acceptance criteria:
- Tomorrow generated text rejects unknown fields, missing required fields,
blank summary, and no usable forecast discussion paragraphs.
- Optional `precipitation_timing` and `confidence` are trimmed and omitted from
normalized JSON when empty.
- Hourly generated text behavior is unchanged.
Suggested validation:
```bash
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
```
This stage is suitable for one implementation prompt.
## Stage 3: Daypart Presentation Fields
Goal: add only the daypart fields needed to keep the Tomorrow template
composable without moving prose construction into Go.
Implementation:
- Extend the `derived_daypart_summaries` module output with presentation
helpers that are facts, not complete sentences:
- `display_name`, for example `Morning`;
- `dominant_condition_lower`, for inline template text;
- `temperature_phrase_f`, such as `low 70s`, `upper 60s`, or
`upper 60s to mid-70s`;
- `mention_precipitation`, true when max PoP is at or above the existing
hourly forecast precipitation mention threshold, currently 20%;
- `max_pop_time_label`, using friendly local hour format such as `8:00 AM`
when max PoP time exists.
- Reuse the existing hourly forecast precipitation mention threshold constant
rather than adding a user config field in this change.
- Keep existing structured numeric fields in the module output.
- Do not add a prewritten `daypart_line` string.
- Do not add wind prose in this stage unless tests show the initial template
needs a specific structured wind fact. If wind wording is needed, add a
small structured wind field, not a full sentence.
Acceptance criteria:
- Daypart module output has enough structured fields for a readable Tomorrow
template.
- The output remains useful for YAML prompt packages.
- No generated prose sentence is hard-coded into the module.
Suggested validation:
```bash
go test ./internal/briefing ./internal/promptinput
```
This stage is suitable for one implementation prompt.
## Stage 4: Tomorrow Render Context And Template
Goal: render Tomorrow Markdown from structured generated text, module outputs,
and report metadata.
Implementation:
- Add a dedicated Tomorrow render context under `internal/generatedtext`, for
example:
```go
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
```
- Add `TomorrowReportContext` with:
- `Title`, for example `Sunday's Weather`;
- `ForecastDate`;
- `ForecastDateLabel`, for example `Sunday, June 15, 2026`;
- `ForecastDayName`, for example `Sunday`;
- `GeneratedAt`;
- `GeneratedAtLabel`, for example
`Saturday, June 14, 2026 at 9:14 AM`;
- `ValidPeriod`;
- `Timezone`.
- Construct `Title` in Go, not in the template.
- Add `TomorrowTemplateModules` with pointer fields for the module outputs used
by the template:
- `Metadata`;
- `DerivedDailySummary`;
- `DerivedDaypartSummaries`;
- `PrecipTiming`;
- `AlertDigest`;
- `SPCConvectiveOutlooks`;
- `AreaForecastDiscussion`;
- `SPCConvectiveDiscussion`;
- `WeatherStory`;
- `TomorrowPlanning`.
- Add an ordered daypart slice for the template, derived from the configured
daypart order rather than ranging directly over a map. This can live in
`TomorrowTemplateModules`, for example `Dayparts []TomorrowDaypartContext`.
- Add `BuildTomorrowRenderContext`.
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
- Add Tomorrow to `internal/reporttemplate` template lookup.
- Template shape:
- title;
- forecast date;
- generated timestamp;
- `GeneratedText.Summary`;
- deterministic `Daypart Forecast` bullets in configured order;
- conditional `Precipitation Timing` only when precipitation windows exist;
- deterministic precipitation window bullets before optional
`GeneratedText.PrecipitationTiming`;
- `Forecast Discussion` with one paragraph per
`GeneratedText.ForecastDiscussion` item.
- Keep current conditions and hourly forecast available through the data
package and render context, but do not render them in the initial Tomorrow
template unless the template explicitly uses them.
Acceptance criteria:
- The template renders without map-order nondeterminism.
- Precipitation Timing is omitted when no precipitation windows exist.
- Forecast Discussion supports multiple paragraphs.
- Missing optional modules produce clean omission or fallback behavior, not
template execution errors.
Suggested validation:
```bash
go test ./internal/generatedtext ./internal/reporttemplate ./internal/briefing
```
This stage is suitable for one implementation prompt.
## Stage 5: App Workflow Integration
Goal: route Tomorrow through the generated-text-template workflow end to end.
Implementation:
- Change Tomorrow report definition to:
- `PromptID: "weather.tomorrow_generated_text"`
- `GenerationMode: report.GenerationModeGeneratedTextTemplate`
- `TemplateID: "tomorrow"`
- `GeneratedTextSchemaID: "tomorrow"`
- Update `internal/app.buildRenderContext` dispatch:
- hourly template requires `generatedtext.Hourly`;
- tomorrow template requires `generatedtext.Tomorrow`;
- unsupported type/template combinations return actionable errors.
- Ensure Scriptorium `run` writes raw generated text to a `.json` path for
Tomorrow, matching the existing generated-text-template workflow.
- Ensure normalized generated text, render context JSON, generated Markdown,
metadata, and final report artifacts are persisted through existing state
helpers.
- Ensure app errors include report ID `tomorrow` and RunID context.
Acceptance criteria:
- `weatherreporter generate tomorrow` no longer invokes Scriptorium for full
Markdown.
- The workflow validates Scriptorium JSON output, builds a Tomorrow render
context, and renders Markdown locally.
- Hourly generated-text-template behavior remains unchanged.
Suggested validation:
```bash
go test ./internal/app ./internal/state ./internal/adapters/scriptorium
```
This stage is suitable for one implementation prompt.
## Stage 6: CLI, State, Distributor, And Batch Behavior
Goal: update cross-package behavior affected by the report ID clean break.
Implementation:
- Update CLI tests and command-output expectations for `generate tomorrow`.
- Update state/path tests so managed artifacts, snapshots, data packages,
generated-text artifacts, render contexts, and report files use artifact group
`tomorrow`.
- Update batch tests:
- evening batch emits report ID `tomorrow`;
- batch output copy name remains `tomorrow.md`;
- partial-failure behavior is unchanged.
- Update Recent Changes tests:
- Tomorrow compares only against prior `tomorrow` snapshots;
- Daily Today no longer treats Tomorrow as a compatible prior unless the
implementation explicitly keeps that relationship for Daily Today only.
- Update distributor notification tests so rendered template variables use:
- `report_id=tomorrow`;
- `artifact_group=tomorrow`;
- `batch_output_name=tomorrow.md`.
- Update config tests so report module overrides use `reports.tomorrow`.
Do not accept `reports.daily_tomorrow` unless a future explicit
compatibility decision reverses the clean break.
Acceptance criteria:
- Public CLI syntax is stable.
- Persisted artifacts and distributor request context use the new report ID.
- Batch behavior is unchanged except for the new ID.
- No tests rely on `daily_tomorrow`.
Suggested validation:
```bash
go test ./internal/cli ./internal/app ./internal/state ./internal/config ./internal/report
```
This stage is suitable for one implementation prompt.
## Stage 7: Documentation And Examples
Goal: align implemented docs and maintained examples after the code change.
Implementation:
- Update non-roadmap docs only after the behavior is implemented.
- Inspect and update:
- `docs/cli.md`;
- `docs/config.md`;
- `docs/operations.md`;
- `docs/troubleshooting.md`, if new failure modes are introduced;
- `docs/internal/report-registry.md`;
- `docs/internal/generatedtext.md`;
- `docs/internal/reporttemplate.md`;
- `docs/internal/state.md`;
- `docs/templates.md`;
- relevant Scriptorium and distributor integration docs only if their
weatherreporter-facing contract changed.
- Update `examples/config.yml` if it references Tomorrow modules or
`reports.daily_tomorrow`.
- Keep future Today/Daily split language only under `docs/roadmap/`.
- Do not document unimplemented Today or generic Daily products as current
behavior.
Acceptance criteria:
- Non-roadmap docs describe implemented behavior only.
- Docs use report ID `tomorrow`.
- Examples load under current config validation.
- No stale user-facing references to `daily_tomorrow` remain outside historical
roadmap context.
Suggested validation:
```bash
rg -n "daily_tomorrow|Daily Tomorrow|weather.daily_report" README.md docs examples internal
git diff --check
```
This stage is suitable for one implementation prompt.
## Stage 8: Final Validation
Goal: verify the completed cutover as a coherent behavior change.
Run:
```bash
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Also run targeted stale-symbol checks:
```bash
rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples
rg -n "weather.tomorrow_generated_text|TemplateID:.*tomorrow|GeneratedTextSchemaID:.*tomorrow" internal docs
```
Acceptance criteria:
- Full test suite passes.
- Help output still shows `generate tomorrow`.
- No production-code stale `daily_tomorrow` symbols remain.
- Generated-text-template artifacts for Tomorrow are persisted in the same
categories as Hourly.
- Existing Hourly behavior still passes tests.
This stage is suitable for one implementation prompt.
## Open Questions
None block implementation. The required decisions are locked by
`docs/roadmap/tomorrow.md` and this implementation plan:
- Tomorrow uses report ID `tomorrow`.
- Tomorrow uses prompt ID `weather.tomorrow_generated_text`.
- Tomorrow uses template ID and generated-text schema ID `tomorrow`.
- Tomorrow forecast discussion is an array of paragraph strings.
- `daily_tomorrow` compatibility aliases are intentionally not preserved.
## Global Validation Checklist
- `go test ./...`
- `go run ./cmd/weatherreporter --help`
- `git diff --check`
- `rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples`
- Confirm `weatherreporter generate tomorrow` uses structured JSON from
Scriptorium and renders final Markdown locally.
- Confirm distributor notification context uses `report_id=tomorrow` and
`artifact_group=tomorrow`.
- Confirm examples contain no unimplemented fields and no secrets.

261
docs/roadmap/today.md Normal file
View File

@@ -0,0 +1,261 @@
# Today Report Roadmap
## Purpose
This roadmap defines the work needed to add a new `today` report type. The
report is not implemented. Current report behavior is documented outside
`docs/roadmap/`.
## Intent
Today should be an independent generated-text-template report focused on the
current local civil day. It should mirror the implemented Tomorrow Report
structure, but its valid period, identity, prompt, template, schema, workspace
paths, and distributor identity should use `today`.
Today should not replace the existing Daily Today report in the same change.
Daily Today currently remains the direct-Markdown `daily` command and report ID
`daily_today`. The Today report should be added as a separate product so the
project can compare the generated-text-template version with the existing
direct-Markdown daily report before deciding whether to retire or rename either
one.
## Target Report Shape
Today should render from deterministic current-day facts plus structured prose
from Scriptorium:
```markdown
# Today's Weather
**Forecast date:** Friday, May 29, 2026
**Updated:** Friday, May 29, 2026 at 8:30 AM
<GeneratedText summary>
## Daypart Forecast
- **Morning:** <deterministic daypart line>
- **Midday:** <deterministic daypart line>
- **Afternoon:** <deterministic daypart line>
- **Evening:** <deterministic daypart line>
## Precipitation Timing
- **10:00 AM** to **1:00 PM**: Precipitation is expected during this period.
The peak precipitation chance is 70% at 11:00 AM.
- <optional GeneratedText precipitation_timing>
## Forecast Discussion
<GeneratedText forecast_discussion paragraphs>
```
The precipitation section should render only when precipitation windows exist
for the current-day valid period. The deterministic daypart forecast should
follow the same module-driven presentation approach used by Tomorrow.
## Report Identity
Add a report definition with:
- report ID: `today`
- public generate command: `today`
- prompt ID: `weather.today_generated_text`
- generation mode: `generated_text_template`
- template ID: `today`
- generated-text schema ID: `today`
- artifact group: `today`
- batch output name: `today.md`
- prior compatibility: Today Report only
- comparison strategy: same valid date
- valid period: current local civil day in the effective report timezone
Do not change existing `daily`, `daily_today`, or morning batch behavior in the
same implementation unless a separate roadmap explicitly calls for that change.
## GeneratedText Contract
Today should use the same structured prose shape as Tomorrow:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
Required:
- `summary`
- `forecast_discussion`
Optional:
- `precipitation_timing`
- `confidence`
Validation should match Tomorrow semantics:
- reject malformed JSON and unknown fields
- reject trailing JSON values
- trim `summary`, `precipitation_timing`, and `confidence`
- trim each `forecast_discussion` paragraph
- drop blank discussion paragraphs
- require at least one nonblank discussion paragraph
- return canonical normalized JSON with the same public field names
## Template Context
Add dedicated Today types under `internal/generatedtext`, rather than reusing
Tomorrow types directly. The shape should mirror Tomorrow so template behavior
stays explicit and testable:
```go
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
```
`TodayReportContext` should include:
- `Title`, for example `Today's Weather`
- `ForecastDate`
- `ForecastDateLabel`, for example `Friday, May 29, 2026`
- `ForecastDayName`, for example `Friday`
- `GeneratedAt`
- `GeneratedAtLabel`
- `ValidPeriod`
- `Timezone`
`TodayTemplateModules` should expose the same categories the Today template
needs:
- `Metadata`
- `CurrentConditions`
- `HourlyForecast`
- `DerivedDailySummary`
- `DerivedDaypartSummaries`
- ordered daypart rows
- `PrecipTiming`
- `AlertDigest`
- `SPCConvectiveOutlooks`
- `AreaForecastDiscussion`
- `SPCConvectiveDiscussion`
- `WeatherStory`
Add `TodayPlanning` only if deterministic today-specific planning facts are
introduced. Do not reuse `TomorrowPlanning` for Today.
## Module Composition
The default module composition should mirror Tomorrow where the same facts are
useful for the current-day report:
- `metadata`
- `current_conditions`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story`
- `hourly_forecast`
Keep module outputs deterministic and prompt-facing. Do not add prose-only Go
fields unless the template needs a structured fact that cannot be expressed from
existing module data.
## Implementation Plan
1. Add Today report identity.
- Add `report.Today`.
- Add command-name and config-key support for `today`.
- Add a report definition with generated-text-template identity fields.
- Add current-local-day valid-period resolution.
- Add report tests for ID, prompt ID, artifact group, command mapping,
config key mapping, and valid period.
2. Add Today generated-text validation and schema.
- Add `generatedtext.Today`.
- Add `ValidateToday`.
- Add `today.generated_text.schema.json`.
- Add generated-text validation tests matching Tomorrow coverage.
3. Add Today template and render context.
- Add `today.md.tmpl`.
- Add `TodayRenderContext`, `TodayReportContext`, and
`TodayTemplateModules`.
- Add `BuildTodayRenderContext`.
- Reuse the internal snapshot lookup helper for module extraction.
- Add render-context and template tests for populated and omitted optional
modules.
4. Register Today in the generated-text catalog and embedded asset lookups.
- Add a generated-text catalog entry.
- Add reporttemplate template/schema map entries.
- Extend catalog completeness tests.
- Add unsupported schema/template combination coverage if needed.
5. Integrate app and CLI workflows.
- Ensure `weatherreporter generate today` resolves and runs through the
generated-text-template workflow.
- Persist raw generated text, structured run result, normalized generated
text, render context, Markdown report, metadata, and optional output copy.
- Ensure distributor notification uses the managed Today Markdown report
path and Today template values.
6. Update current-behavior docs after implementation.
- Update `docs/cli.md`, `docs/config.md`, `docs/operations.md`,
`docs/templates.md`, `docs/internal/report-registry.md`,
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`,
`docs/internal/app-orchestration.md`, and maintained examples as needed.
- Keep any unresolved Daily Today replacement decision only in roadmap docs.
## Test Plan
Add or update tests for:
- report registry membership and command/config-key resolution
- current-day valid period in the configured timezone
- generated-text unknown fields, trailing JSON, missing required fields, blank
fields, paragraph trimming, and canonical output
- generated-text catalog completeness
- template/schema asset lookup
- render context labels, module extraction, nil optional modules, and daypart
ordering
- app workflow artifacts for `generate today`
- optional `--out` copy behavior
- distributor template values and managed report notification source
- CLI parser support for `generate today`
- config report module overrides for `reports.today`
Suggested validation:
```sh
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate
go test ./internal/app ./internal/cli ./internal/config
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
## Open Decisions
- Whether Today should eventually replace the existing Daily Today report.
- Whether morning batch should include Today, Daily Today, or both.
- Whether Today needs a dedicated deterministic planning module.
- Whether the public `daily` command should remain direct Markdown after Today
exists.
Do not resolve these decisions implicitly while adding the initial `today`
report. Keep the first implementation narrowly focused on a separate
current-day generated-text-template report.

View File

@@ -1,276 +0,0 @@
# Tomorrow Report Roadmap
## Purpose
This roadmap defines the target state for making Tomorrow an independent
generated-text-template report. The feature is not implemented yet, so this
document lives under `docs/roadmap/`.
## Intent
Tomorrow should become its own report product, not a variant of the Daily
Report. The current CLI command `weatherreporter generate tomorrow` should
remain, but the internal report ID, prompt ID, template, schema, workspace
paths, and distributor identity should use `tomorrow`.
The report should combine deterministic daypart and precipitation facts with
LLM prose for the high-level summary, optional precipitation context, and
forecast discussion. The resulting Markdown should be predictable and
template-driven, similar to the implemented Hourly Report.
Longer term, Today, Tomorrow, and Daily may all become separate report products
with different prompts, templates, and deterministic sections. This roadmap
starts that split with Tomorrow.
## Target Report Shape
Example structure:
```markdown
# Sunday's Weather
**Forecast Date:** Sunday, June 15, 2026
**Generated:** Saturday, June 14, 2026 at 9:14 AM
<GeneratedText summary>
## Daypart Forecast
- **Overnight:** <deterministic daypart line>
- **Morning:** <deterministic daypart line>
- **Midday:** <deterministic daypart line>
- **Afternoon:** <deterministic daypart line>
- **Evening:** <deterministic daypart line>
## Precipitation Timing
- **1:00 AM** to **5:00 AM**: Precipitation is expected during this period. The peak precipitation chance is 59% at 2:00 AM.
- <optional GeneratedText precipitation_timing>
## Forecast Discussion
<GeneratedText forecast_discussion paragraphs>
```
`Precipitation Timing` should render only when at least one precipitation
window exists for the valid period. The threshold for precipitation windows
remains the existing precipitation-window threshold, currently 40%.
## Locked Decisions
- Replace report ID `daily_tomorrow` with `tomorrow`.
- Do not preserve compatibility aliases for `daily_tomorrow`; this is a
pre-release clean break.
- Keep public CLI syntax: `weatherreporter generate tomorrow`.
- Use generated-text-template generation for Tomorrow, not full Markdown
generation by Scriptorium.
- Use a dedicated Scriptorium prompt ID, template ID, and schema ID:
- prompt ID: `weather.tomorrow_generated_text`
- template ID: `tomorrow`
- generated-text schema ID: `tomorrow`
- Use `ArtifactGroup: "tomorrow"` and `BatchOutputName: "tomorrow.md"`.
- Use `CompatiblePriorIDs: []report.ID{report.Tomorrow}`.
- Keep valid-period behavior: Tomorrow covers the next local civil day.
- Keep Morning/Evening batch behavior unless explicitly changed later; evening
batch should still include Tomorrow.
- Future Today/Daily split is out of scope for this roadmap.
## GeneratedText Contract
Add a Tomorrow GeneratedText schema:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
Required:
- `summary`
- `forecast_discussion`
Optional:
- `precipitation_timing`
- `confidence`
`forecast_discussion` should be an array of paragraph strings so Scriptorium
can return multi-paragraph discussion without embedding paragraph delimiters in
one string. Empty or whitespace-only discussion paragraphs should be rejected or
trimmed out during validation; after trimming, at least one paragraph is
required.
`confidence` may be validated and persisted but does not need to render in the
initial template.
## Template Context
Add a dedicated Tomorrow render context rather than reusing Hourly context
types.
Recommended top-level shape:
```go
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
```
`TomorrowReportContext` should include:
- `Title`: for example `Sunday's Weather`
- `ForecastDate`: canonical local forecast date if useful
- `ForecastDateLabel`: for example `Sunday, June 15, 2026`
- `ForecastDayName`: for example `Sunday`
- `GeneratedAt`
- `GeneratedAtLabel`: for example `Saturday, June 14, 2026 at 9:14 AM`
- `ValidPeriod`
- `Timezone`
Do not derive the possessive title in the template. Go should provide `Title`
so wording is consistent and easy to test.
`TomorrowTemplateModules` should expose the module outputs needed by the
template:
- `Metadata`
- `DerivedDailySummary`
- `DerivedDaypartSummaries`
- `PrecipTiming`
- `AlertDigest`
- `SPCConvectiveOutlooks`
- `AreaForecastDiscussion`
- `SPCConvectiveDiscussion`
- `WeatherStory`
- `TomorrowPlanning`, if still useful
Current conditions and hourly forecast can remain in the module snapshot and
data package if useful for Scriptorium, but they do not need to render in the
initial Tomorrow template unless a later design calls for them.
## Daypart Forecast
The Daypart Forecast should be deterministic but composable. Avoid adding a
single prewritten Go `DaypartLine` string that makes template wording rigid.
Add presentation-friendly fields to `derived_daypart_summaries` only where they
avoid awkward template logic. Likely useful fields:
- display name, such as `Overnight` or `Morning`;
- lower-case dominant condition text for inline sentences;
- rounded temperature range phrase if available;
- precipitation mention flag using the existing hourly line mention threshold
concept, currently 20%;
- max precipitation probability and friendly max time;
- optional wind phrase or wind range only if deterministic wind wording is
clearly needed.
The initial implementation may keep daypart bullet wording simple. It should be
easy to revise the template text without editing Go unless new facts are
needed.
## Implementation Plan
1. Report identity split
- Rename `report.DailyTomorrow` to `report.Tomorrow` with ID `tomorrow`.
- Update registry order, resolver references, CLI mapping, batch selection,
state metadata expectations, docs, and tests.
- Preserve `weatherreporter generate tomorrow`.
- Accept that workspace paths, RunIDs, distributor bundle IDs, and report
URLs change from `daily_tomorrow` to `tomorrow`.
2. GeneratedText contract
- Add `generatedtext.Tomorrow`, validation, normalized JSON output, and
tests.
- Add `tomorrow.generated_text.schema.json`.
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
maintained prompt source asset.
- Update app validation dispatch to use the Tomorrow schema.
3. Template and render context
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
- Add Tomorrow template/schema lookup entries.
- Add `BuildTomorrowRenderContext`.
- Update app render-context dispatch for template ID `tomorrow`.
- Persist render context in the existing generated-text-template workflow.
4. Module presentation fields
- Add only the daypart presentation fields needed by the template.
- Reuse the existing precipitation window hour-label fields.
- Keep deterministic weather derivation in Go and wording/layout in the
template.
5. Report definition conversion
- Change Tomorrow report definition to:
- `PromptID: "weather.tomorrow_generated_text"`
- `GenerationMode: generated_text_template`
- `TemplateID: "tomorrow"`
- `GeneratedTextSchemaID: "tomorrow"`
- `ArtifactGroup: "tomorrow"`
- `BatchOutputName: "tomorrow.md"`
- compatible prior IDs containing only `tomorrow`
- Review module composition and keep only modules used by the prompt,
template, or future inspection value.
6. Documentation and examples
- After implementation, update non-roadmap docs for implemented behavior:
`docs/cli.md`, `docs/operations.md`, `docs/internal/report-registry.md`,
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`, and
`docs/templates.md`.
- Update examples that refer to `reports.tomorrow` or report module
overrides if the config key changes.
## Test Plan
- Report tests:
- registry contains `tomorrow`, not `daily_tomorrow`;
- `generate tomorrow` resolves report ID `tomorrow`;
- valid period remains next local civil day;
- evening batch still includes Tomorrow;
- RunID and artifact paths use `tomorrow`.
- GeneratedText tests:
- `summary` and non-empty `forecast_discussion` are required;
- `forecast_discussion` trims paragraph strings and rejects/omits blanks;
- optional `precipitation_timing` and `confidence` normalize correctly;
- unknown fields are rejected.
- Template tests:
- title renders as `<weekday>'s Weather`;
- forecast date and generated labels render;
- daypart bullets render in configured daypart order;
- precipitation section is omitted when no precipitation windows exist;
- precipitation section includes deterministic windows and optional LLM text
when windows exist;
- forecast discussion renders multiple paragraphs.
- App/CLI workflow tests:
- `weatherreporter generate tomorrow` uses structured Scriptorium output and
the template renderer;
- raw generated text, validated generated text, render context, report, and
metadata artifacts are persisted;
- optional `--out` behavior remains unchanged;
- distributor notification uses report ID/artifact group `tomorrow`.
Validation commands:
```bash
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
## Open Questions
None block implementation. Recommended defaults are:
- make `forecast_discussion` an array of strings for Tomorrow;
- keep Hourly GeneratedText unchanged for now;
- do not add Daily Today or generic Daily report splits in this change;
- do not preserve `daily_tomorrow` compatibility aliases.

View File

@@ -130,18 +130,32 @@ type bundleBuilder struct {
fetchedAt time.Time
}
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json.RawMessage
source weatherdata.Source
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
if err != nil {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
endpoint: "/observations",
query: queryOptions{precision: true},
missingMessage: "observation data is missing",
}, &observation)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation
b.addSource(source)
@@ -149,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
if err != nil {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
endpoint: "/conditions/current",
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
b.bundle.Current = &current
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
if err != nil {
var hourly weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "hourly",
endpoint: "/forecast/hourly",
query: queryOptions{precision: true, timezone: true},
missingMessage: "hourly forecast data is missing",
required: true,
decodeLabel: "hourly forecast",
}, &hourly)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly weatherdata.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
source := fetched.source
if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
}
@@ -188,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
}
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
if err != nil {
var narrative weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
missingMessage: "narrative forecast data is missing",
}, &narrative)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative weatherdata.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative
@@ -221,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
}
var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
@@ -233,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
if err != nil {
var discussion weatherdata.Discussion
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
endpoint: "/discussion",
query: queryOptions{timezone: true},
missingMessage: "forecast discussion data is missing",
}, &discussion)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion weatherdata.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion
@@ -252,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
}
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
if err != nil {
var story weatherdata.WeatherStory
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",
}, &story)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
}
@@ -273,17 +289,17 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
}
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
if err != nil {
var run weatherdata.ConvectiveOutlookRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: sourceSPCConvectiveOutlooks,
endpoint: convectiveOutlooksEndpoint,
query: queryOptions{timezone: true, omitUnits: true},
missingMessage: "SPC convective outlook data is missing",
}, &run)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
}
var run weatherdata.ConvectiveOutlookRun
if err := decodeSource(raw, &run); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
@@ -295,6 +311,28 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(ctx, request)
if err != nil || !ok {
return fetchedSource{}, false, err
}
if err := decodeSource(fetched.raw, target); err != nil {
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
}
return fetched, true, nil
}
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
if err != nil {
return fetchedSource{}, false, err
}
if raw == nil {
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
}
return fetchedSource{raw: raw, source: source}, true, nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true
if required {
@@ -303,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
return b.applyMissingPolicy(source, "missing_source", message)
}
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
if request.required {
label := request.name
if request.decodeLabel != "" {
label = request.decodeLabel
}
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
}
source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))

View File

@@ -70,6 +70,24 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
}
wantPaths := []string{
"/observations",
"/conditions/current",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
}
for _, want := range wantPaths {
if !containsPath(requested, want) {
t.Fatalf("requested paths = %v, want %s", requested, want)
}
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
}

View File

@@ -21,7 +21,6 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -30,19 +29,19 @@ import (
type ReportKind string
const (
ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow"
ReportHourly ReportKind = "hourly"
ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm"
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
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
const (
BatchMorning BatchKind = "morning"
BatchEvening BatchKind = "evening"
BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
)
type GenerateRequest struct {
@@ -345,7 +344,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
if err != nil {
return report.Resolved{}, err
}
id, err := reportIDForCommand(req.Report)
id, err := report.IDForCommandName(string(req.Report))
if err != nil {
return report.Resolved{}, err
}
@@ -367,7 +366,7 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
if err != nil {
return nil, err
}
batch, err := reportBatchForCommand(req.Batch)
batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil {
return nil, err
}
@@ -382,43 +381,17 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
}
func reportRegistry(cfg config.Config) (report.Registry, error) {
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
return report.Registry{}, err
}
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
if err != nil {
return report.Registry{}, err
}
return registry, nil
}
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.Tomorrow, nil
case ReportHourly:
return report.Hourly, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
@@ -585,32 +558,16 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath,
OutputPath: reportPath,
})
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
}
metadata.RenderedReportPath = reportPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil {
return nil, runErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
}
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,
})
if err != nil {
return nil, err
}
@@ -622,15 +579,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath,
PreflightPath: preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: metadata,
MetadataPath: metadataPath,
OutputPath: finalized.OutputPath,
NotificationPath: finalized.NotificationPath,
Metadata: finalized.Metadata,
MetadataPath: finalized.MetadataPath,
PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges,
RenderResult: renderResult,
RunResult: runResult,
Notification: notification,
Notification: finalized.Notification,
}, nil
}
@@ -654,6 +611,11 @@ type generatedReportRequest struct {
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
}
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
@@ -680,7 +642,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
}
generatedText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
}
@@ -694,7 +656,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
return nil, err
}
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, generatedText)
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
@@ -708,7 +670,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
return nil, err
}
rendered, err := reporttemplate.Render(req.Resolved.Definition.TemplateID, renderContext)
rendered, err := handler.Render(renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
@@ -719,32 +681,18 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
req.metadata.RenderedReportPath = reportPath
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata)
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: req.store,
Resolved: req.Resolved,
Metadata: req.metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
})
if err != nil {
return nil, err
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
}
notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store)
if notificationPath != "" {
req.metadata.NotificationPath = notificationPath
metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if notificationErr != nil {
return nil, notificationErr
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
@@ -753,10 +701,10 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: req.metadata,
MetadataPath: metadataPath,
OutputPath: finalized.OutputPath,
NotificationPath: finalized.NotificationPath,
Metadata: finalized.Metadata,
MetadataPath: finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
@@ -765,10 +713,80 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
Notification: notification,
Notification: finalized.Notification,
}, nil
}
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
ManagedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
}
type finalizeRenderedReportResult struct {
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
}
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
}
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
outputPath := req.ManagedReportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
return finalizeRenderedReportResult{}, err
}
}
}
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return finalizeRenderedReportResult{}, err
}
result := finalizeRenderedReportResult{
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
}
if req.GenerationErr != nil {
return result, req.GenerationErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return finalizeRenderedReportResult{}, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
result.NotificationPath = notificationPath
}
result.Notification = notification
if err != nil {
return result, err
}
return result, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier)
if !enabled {
@@ -1113,36 +1131,6 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
}
}
func validateGeneratedText(definition report.Definition, data []byte) (any, []byte, error) {
switch definition.GeneratedTextSchemaID {
case "hourly":
return generatedtext.ValidateHourly(data)
case "tomorrow":
return generatedtext.ValidateTomorrow(data)
default:
return nil, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
}
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, generated any) (any, error) {
switch definition.TemplateID {
case "hourly":
hourly, ok := generated.(generatedtext.Hourly)
if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", definition.TemplateID, definition.ID)
}
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
case "tomorrow":
tomorrow, ok := generated.(generatedtext.Tomorrow)
if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", definition.TemplateID, definition.ID)
}
return generatedtext.BuildTomorrowRenderContext(metadata, snapshot, tomorrow, reportFacts.Collected, reportFacts.Derived)
default:
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
}
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil

View File

@@ -16,7 +16,6 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -646,6 +645,73 @@ 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: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\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)
}
})
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,
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)
}
})
}
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
@@ -820,7 +886,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
}
}
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) {
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
resolved.Definition.TemplateID = "missing-template"
renderer := &recordingRenderer{
@@ -837,77 +903,21 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *te
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "build render context")
assertGeneratedReportError(t, err, resolved, "lookup generated text catalog")
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
t.Fatalf("calls structured=%d run=%d, want no generated text run", renderer.structuredRunCalls, renderer.runCalls)
}
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
assertPathsExist(t, paths.Preflight, paths.Metadata)
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedText) {
t.Fatalf("metadata missing generated text link:\n%s", string(metadataData))
}
}
func TestGeneratedTextValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyDefinition := report.DefaultRegistry().MustLookup(report.Hourly)
hourly, normalized, err := validateGeneratedText(hourlyDefinition, []byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. "
}`))
if err != nil {
t.Fatalf("validateGeneratedText(hourly) error = %v", err)
}
if _, ok := hourly.(generatedtext.Hourly); !ok {
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
}
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
}
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
tomorrow, normalized, err := validateGeneratedText(tomorrowDefinition, []byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("validateGeneratedText(tomorrow) error = %v", err)
}
if _, ok := tomorrow.(generatedtext.Tomorrow); !ok {
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
_, err := buildRenderContext(definition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("buildRenderContext() error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires hourly generated text`) {
t.Fatalf("buildRenderContext() error = %v, want hourly generated text requirement", err)
}
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
_, err = buildRenderContext(tomorrowDefinition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
if err == nil {
t.Fatal("buildRenderContext() error = nil, want tomorrow type mismatch")
}
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("buildRenderContext() error = %v, want tomorrow generated text requirement", err)
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
t.Fatalf("metadata missing generated text artifact path:\n%s", string(metadataData))
}
}
@@ -1885,6 +1895,24 @@ reports:
}
}
func TestResolveGenerateRejectsInvalidProgrammaticReportOverrides(t *testing.T) {
cfg := config.Defaults()
cfg.Reports = map[string]config.ReportConfig{
"moon": {},
}
_, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err == nil {
t.Fatal("ResolveGenerate() error = nil, want report override error")
}
if !strings.Contains(err.Error(), "reports.moon") {
t.Fatalf("ResolveGenerate() error = %q, want report override context", err.Error())
}
}
func moduleIDsForTest(ids []module.ID) []string {
out := make([]string, 0, len(ids))
for _, id := range ids {
@@ -2122,6 +2150,28 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
if !failedThreeDay {
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
}
cfg.Workspace.Root = t.TempDir()
err = RunBatch(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Renderer: &selectiveRenderer{
runBody: "# Batch Report\n",
},
Notifier: &recordingNotifier{
errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"),
},
},
})
var batchErr BatchError
if !errors.As(err, &batchErr) {
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err)
}
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 {
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
}
}
func TestRunBatchUsesOutputDirectory(t *testing.T) {
@@ -2152,6 +2202,17 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected output copy %q: %v", want, err)
}
reportData, err := os.ReadFile(result.Reports[0].ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(want)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("batch output copy differs from managed report")
}
}
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
@@ -2343,6 +2404,19 @@ func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolv
return paths
}
func readMetadataForTest(t *testing.T, path string) state.Metadata {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read metadata %q: %v", path, err)
}
var metadata state.Metadata
if err := json.Unmarshal(data, &metadata); err != nil {
t.Fatalf("decode metadata %q: %v", path, err)
}
return metadata
}
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper()
if err == nil {

View File

@@ -186,10 +186,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
}
reportKind, ok := reportKind(args[0])
if !ok {
if _, err := report.IDForCommandName(args[0]); err != nil {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
}
reportKind := app.ReportKind(args[0])
opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil {
@@ -250,10 +250,10 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
}
batch, ok := batchKind(args[0])
if !ok {
if _, err := report.BatchForCommandName(args[0]); err != nil {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
}
batch := app.BatchKind(args[0])
opts, err := parseRunFlags(args[1:])
if err != nil {
return app.BatchRequest{}, err
@@ -380,33 +380,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
}
}
func reportKind(value string) (app.ReportKind, bool) {
switch value {
case string(app.ReportDaily):
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportHourly):
return app.ReportHourly, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):
return app.ReportWeekend, true
case string(app.ReportStorm):
return app.ReportStorm, true
default:
return "", false
}
}
func batchKind(value string) (app.BatchKind, bool) {
switch value {
case string(app.BatchMorning):
return app.BatchMorning, true
case string(app.BatchEvening):
return app.BatchEvening, true
default:
return "", false
}
}

View File

@@ -13,53 +13,45 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestRunHelpLongFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"--help"}, &stdout, &stderr)
output, err := runRootCommand(t, "--help")
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String())
if !strings.Contains(output.stdout, "generate daily") {
t.Fatalf("help output missing generate command:\n%s", output.stdout)
}
if !strings.Contains(stdout.String(), "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", stdout.String())
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
}
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
if strings.Contains(stdout.String(), removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", stdout.String())
if strings.Contains(output.stdout, removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", output.stdout)
}
removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
if !strings.Contains(output.stdout, "inspect modules") || strings.Contains(output.stdout, removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", output.stdout)
}
}
func TestRunHelpShortFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"-h"}, &stdout, &stderr)
output, err := runRootCommand(t, "-h")
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(stdout.String(), "weatherreporter run evening") {
t.Fatalf("help output missing run command:\n%s", stdout.String())
if !strings.Contains(output.stdout, "weatherreporter run evening") {
t.Fatalf("help output missing run command:\n%s", output.stdout)
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"unknown"}, &stdout, &stderr)
_, err := runRootCommand(t, "unknown")
if err == nil {
t.Fatal("Run() error = nil, want unknown command error")
}
@@ -76,35 +68,22 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"generate", "storm",
"--config", configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--out", outPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") {
t.Fatalf("data package output missing storm content:\n%s", string(data))
}
assertFileContains(t, dataPackagePath, "id: storm")
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
}
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
@@ -114,39 +93,23 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "tomorrow.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"generate", "tomorrow",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Saturday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
assertFileContains(t, outPath, "# Saturday's Weather")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "id: tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
}
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
if err != nil {
t.Fatalf("glob managed report: %v", err)
}
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
assertFileContains(t, dataPackagePath, "id: tomorrow")
assertFileContains(t, dataPackagePath, "tomorrow_planning:")
reportPath := oneArtifact(t, workspaceRoot, "reports", "tomorrow", "*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
}
}
@@ -154,36 +117,21 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"run", "evening",
"--config", configPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
if err != nil {
t.Fatalf("glob managed report: %v", err)
}
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
_ = oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
reportPath := oneArtifact(t, workspaceRoot, "reports", "tomorrow", "*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
}
}
@@ -191,142 +139,78 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "three-day.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "id: three_day") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
assertFileContains(t, dataPackagePath, "id: three_day")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
}
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "weekend.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"generate", "weekend",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "id: weekend") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing weekend content:\n%s", string(data))
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
assertFileContains(t, dataPackagePath, "id: weekend")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
}
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
_, err := runTestCommand(t, runner,
"run", "morning",
"--config", configPath,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob weekend packages: %v", err)
}
if len(weekendPackages) != 1 {
t.Fatalf("weekend packages = %#v, want one", weekendPackages)
}
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
}
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
output, err := runTestCommand(t, runner,
"run", "morning",
"--config", configPath,
}, &stdout, &stderr)
)
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
}
@@ -335,26 +219,17 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
}
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob weekend packages: %v", err)
}
if len(dailyPackages) != 1 || len(weekendPackages) != 1 {
t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages)
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
}
_ = oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
}
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
@@ -443,28 +318,22 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
output, err := runTestCommand(t, runner,
"run", "evening",
"--config", configPath,
"--out-dir", outputDir,
}, &stdout, &stderr)
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
}
if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
@@ -892,6 +761,36 @@ func TestResolveGenerateCommands(t *testing.T) {
}
}
func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, name := range report.CommandNames() {
t.Run(name, func(t *testing.T) {
args := []string{name}
if name == report.CommandNameDaily {
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)
}
want, err := report.IDForCommandName(name)
if err != nil {
t.Fatalf("IDForCommandName() error = %v", err)
}
resolved, err := app.ResolveGenerate(req, req.Now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != want {
t.Fatalf("resolved ID = %q, want %q", resolved.Definition.ID, want)
}
})
}
}
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml")
@@ -1105,6 +1004,27 @@ func fixedClock() timeutil.Clock {
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
}
type commandOutput struct {
stdout string
stderr string
}
func runRootCommand(t *testing.T, args ...string) (commandOutput, error) {
t.Helper()
return runTestCommand(t, Runner{}, args...)
}
func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput, error) {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), args, &stdout, &stderr)
return commandOutput{
stdout: stdout.String(),
stderr: stderr.String(),
}, err
}
func dailyServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -91,7 +92,10 @@ func TestLoadExampleConfig(t *testing.T) {
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
}
overrides := cfg.ReportModuleOverrides()
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
hourly := overrides[report.Hourly]
if len(hourly) != 9 {
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
@@ -155,7 +159,10 @@ reports:
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.DailyToday]
if len(items) != 6 {
t.Fatalf("daily override length = %d, want 6", len(items))
@@ -192,7 +199,10 @@ reports:
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Hourly]
if len(items) != 4 {
t.Fatalf("hourly override length = %d, want 4", len(items))
@@ -205,6 +215,117 @@ 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 TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any)
if !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
tests := []struct {
name string
reports map[string]ReportConfig
wantErr string
}{
{
name: "DuplicateAlias",
reports: map[string]ReportConfig{
"daily": {},
"daily_today": {},
},
wantErr: "duplicates report override",
},
{
name: "UnknownReport",
reports: map[string]ReportConfig{
"moon": {},
},
wantErr: "reports.moon",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Reports = tt.reports
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want report key error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
cfg := Defaults()
cfg.Reports = map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
}
_, err := cfg.ReportModuleOverrides()
if err == nil {
t.Fatal("ReportModuleOverrides() error = nil, want invalid report key")
}
if !strings.Contains(err.Error(), "reports.moon") {
t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error())
}
}
func TestReportModuleOverrideValidation(t *testing.T) {
tests := []struct {
name string
@@ -270,7 +391,7 @@ reports:
deterministic_modules:
- metadata
`,
wantErr: "reports.daily_tomorrow is not a known report",
wantErr: "reports.daily_tomorrow",
},
{
name: "InvalidOptions",

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -12,23 +11,24 @@ import (
"gopkg.in/yaml.v3"
)
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{}
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
id, err := report.IDForConfigKey(key)
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[id]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[id] = key
if !reportCfg.deterministicModulesSet {
continue
}
id, err := reportIDForConfigKey(key)
if err != nil {
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for _, item := range reportCfg.DeterministicModules {
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
}
overrides[id] = items
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
}
return overrides
return overrides, nil
}
func normalizeReportModules(cfg *Config) error {
@@ -42,9 +42,50 @@ func normalizeReportModules(cfg *Config) error {
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := reportIDForConfigKey(key)
reportID, err := report.IDForConfigKey(key)
if err != nil {
return err
return fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
reportCfg.DeterministicModules[i].Options = options
}
items := moduleItemsFromConfig(reportCfg.DeterministicModules)
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
}
func validateReportModules(cfg Config) error {
if cfg.Reports == nil {
return nil
}
moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil {
return fmt.Errorf("initialize module registry: %w", err)
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
@@ -58,21 +99,31 @@ func normalizeReportModules(cfg *Config) error {
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
options := rawItem.Options
if options != nil {
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
options = normalized
}
reportCfg.DeterministicModules[i].Options = options
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
}
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
}
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem {
out := make([]module.ConfigItem, 0, len(items))
for _, item := range items {
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options})
}
return out
}
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
if raw == nil {
return nil, nil
@@ -84,6 +135,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
if definition.DefaultOptions == nil {
return nil, fmt.Errorf("module %q does not accept options", id)
}
if err := definition.ValidateOptions(raw); err == nil {
return raw, nil
}
optionType := reflect.TypeOf(definition.DefaultOptions)
normalized, err := decodeKnownOptions(raw, optionType)
if err != nil {
@@ -105,23 +159,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
}
return target.Elem().Interface(), nil
}
func reportIDForConfigKey(key string) (report.ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily", "daily_today":
return report.DailyToday, nil
case "tomorrow":
return report.Tomorrow, nil
case "hourly":
return report.Hourly, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":
return report.Weekend, nil
case "storm", "storm_report":
return report.Storm, nil
default:
return "", fmt.Errorf("reports.%s is not a known report", key)
}
}

View File

@@ -9,7 +9,7 @@ import (
)
func Validate(cfg Config) error {
if err := normalizeReportModules(&cfg); err != nil {
if err := validateReportModules(cfg); err != nil {
return err
}
if cfg.WeatherAPI.BaseURL != "" {

View File

@@ -0,0 +1,155 @@
package generatedtext
import (
"fmt"
"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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
)
const (
schemaIDHourly = "hourly"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDTomorrow = "tomorrow"
)
type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
type catalogEntry struct {
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
type Handler struct {
reportID report.ID
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
var catalog = []catalogEntry{
{
schemaID: schemaIDHourly,
templateID: templateIDHourly,
validate: validateHourly,
renderContextBuilder: buildHourlyContext,
},
{
schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow,
validate: validateTomorrow,
renderContextBuilder: buildTomorrowContext,
},
}
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 {
if entry.schemaID == definition.GeneratedTextSchemaID {
schemaKnown = true
}
if entry.templateID == definition.TemplateID {
templateKnown = true
}
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
return Handler{
reportID: definition.ID,
schemaID: entry.schemaID,
templateID: entry.templateID,
validate: entry.validate,
renderContextBuilder: entry.renderContextBuilder,
}, nil
}
}
if !schemaKnown {
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
if !templateKnown {
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
}
func (h Handler) SchemaID() string {
return h.schemaID
}
func (h Handler) TemplateID() string {
return h.templateID
}
func (h Handler) Schema() ([]byte, error) {
data, err := reporttemplate.Schema(h.schemaID)
if err != nil {
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
}
return data, nil
}
func (h Handler) Template() (string, error) {
source, err := reporttemplate.Template(h.templateID)
if err != nil {
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return source, nil
}
func (h Handler) Validate(data []byte) (any, []byte, error) {
if h.validate == nil {
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
}
return h.validate(data)
}
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
if h.renderContextBuilder == nil {
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
}
return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
}
func (h Handler) Render(data any) ([]byte, error) {
rendered, err := reporttemplate.Render(h.templateID, data)
if err != nil {
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return rendered, nil
}
func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data)
}
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
hourly, ok := generated.(Hourly)
if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
}
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
}
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow)
if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
}
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
}

View File

@@ -0,0 +1,164 @@
package generatedtext
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
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 {
t.Fatalf("LookupDefinition() error = %v", err)
}
if handler.SchemaID() != definition.GeneratedTextSchemaID {
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
}
if handler.TemplateID() != definition.TemplateID {
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
}
if handler.validate == nil {
t.Fatal("validator is nil")
}
if handler.renderContextBuilder == nil {
t.Fatal("render-context builder is nil")
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if template == "" {
t.Fatal("Template() returned empty asset")
}
})
}
}
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
tests := []struct {
name string
definition report.Definition
want []string
}{
{
name: "schema",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.GeneratedTextSchemaID = "missing-schema"
return definition
}(),
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
},
{
name: "template",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "missing-template"
return definition
}(),
want: []string{`report template "missing-template"`, `report "hourly"`},
},
{
name: "combination",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "tomorrow"
return definition
}(),
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LookupDefinition(test.definition)
if err == nil {
t.Fatal("LookupDefinition() error = nil, want error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
}
}
})
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. "
}`))
if err != nil {
t.Fatalf("Validate(hourly) error = %v", err)
}
if _, ok := hourly.(Hourly); !ok {
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
}
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(tomorrow) error = %v", err)
}
if _, ok := tomorrow.(Tomorrow); !ok {
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires hourly generated text`) {
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
if err == nil {
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
}
}

View File

@@ -2,10 +2,7 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
@@ -17,20 +14,9 @@ type Hourly struct {
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var value Hourly
if err := decoder.Decode(&value); err != nil {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
} else {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
value, err := decodeGeneratedText[Hourly](data, "hourly")
if err != nil {
return Hourly{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
@@ -44,9 +30,9 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) {
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)
normalized, err := normalizeGeneratedText(value, "hourly")
if err != nil {
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err)
return Hourly{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,33 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
var value T
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
return value, nil
}
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
}
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
normalized, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
}
return normalized, nil
}

View File

@@ -152,39 +152,40 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
current, err := lookup.currentConditions()
if err != nil {
return HourlyTemplateModules{}, err
}
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
hourly, err := lookup.hourlyForecast()
if err != nil {
return HourlyTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
precip, err := lookup.precipTiming()
if err != nil {
return HourlyTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
alerts, err := lookup.alertDigest()
if err != nil {
return HourlyTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return HourlyTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
story, err := lookup.weatherStory()
if err != nil {
return HourlyTemplateModules{}, err
}
@@ -202,51 +203,52 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
}
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
current, err := lookup.currentConditions()
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
hourly, err := lookup.hourlyForecast()
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
daily, err := lookup.derivedDailySummary()
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
precip, err := lookup.precipTiming()
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
alerts, err := lookup.alertDigest()
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
story, err := lookup.weatherStory()
if err != nil {
return TomorrowTemplateModules{}, err
}
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning))
planning, err := lookup.tomorrowPlanning()
if err != nil {
return TomorrowTemplateModules{}, err
}
@@ -267,14 +269,79 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
}, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
output, ok := snapshot.LookupStanza(name)
type moduleSnapshotLookup struct {
snapshot module.Snapshot
stanzas map[string]module.Output
}
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
stanzas[output.StanzaName] = output
}
return moduleSnapshotLookup{
snapshot: snapshot,
stanzas: stanzas,
}
}
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
}
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
}
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
}
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
}
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
}
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
}
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
}
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
}
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
}
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
}
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
}
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
}
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
name := string(id)
output, ok := lookup.stanzas[name]
if !ok || output.Value == nil {
return nil, nil
}
value, _, err := module.StanzaValue[T](snapshot, name)
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
if err != nil {
return nil, fmt.Errorf("build render context: %w", err)
return nil, fmt.Errorf("build render context module %q: %w", id, err)
}
return &value, nil
}

View File

@@ -109,8 +109,30 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.Modules.HourlyForecast != nil {
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
if ctx.Modules.CurrentConditions == nil {
t.Fatal("Modules.CurrentConditions = nil, want populated module")
}
if ctx.Modules.HourlyForecast != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.AlertDigest != nil {
t.Fatalf("Modules = %#v, want omitted optional modules to remain nil", ctx.Modules)
}
}
func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: "not a current conditions stanza"},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error")
}
if !strings.Contains(err.Error(), string(module.CurrentConditions)) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want module ID", err)
}
}
@@ -212,7 +234,7 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
}
rendered, err := reporttemplate.Render("tomorrow", ctx)

View File

@@ -1,10 +1,7 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
@@ -16,20 +13,9 @@ type Tomorrow struct {
}
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var value Tomorrow
if err := decoder.Decode(&value); err != nil {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
}
} else {
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
value, err := decodeGeneratedText[Tomorrow](data, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
@@ -43,9 +29,9 @@ func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)
normalized, err := normalizeGeneratedText(value, "tomorrow")
if err != nil {
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err)
return Tomorrow{}, nil, err
}
return value, normalized, nil
}

86
internal/report/names.go Normal file
View File

@@ -0,0 +1,86 @@
package report
import (
"fmt"
"strings"
)
const (
CommandNameDaily = "daily"
CommandNameTomorrow = "tomorrow"
CommandNameHourly = "hourly"
CommandNameThreeDay = "three-day"
CommandNameWeekend = "weekend"
CommandNameStorm = "storm"
BatchNameMorning = "morning"
BatchNameEvening = "evening"
)
func IDForCommandName(name string) (ID, error) {
switch name {
case CommandNameDaily:
return DailyToday, nil
case CommandNameTomorrow:
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)
}
}
func CommandNames() []string {
return []string{
CommandNameDaily,
CommandNameTomorrow,
CommandNameHourly,
CommandNameThreeDay,
CommandNameWeekend,
CommandNameStorm,
}
}
func IDForConfigKey(key string) (ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily", "daily_today":
return DailyToday, nil
case "tomorrow":
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)
}
}
func BatchForCommandName(name string) (Batch, error) {
switch name {
case BatchNameMorning:
return Morning, nil
case BatchNameEvening:
return Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", name)
}
}
func BatchCommandNames() []string {
return []string{
BatchNameMorning,
BatchNameEvening,
}
}

View File

@@ -240,6 +240,113 @@ func TestBatchesDoNotIncludeHourly(t *testing.T) {
}
}
func TestIDForCommandName(t *testing.T) {
tests := []struct {
name string
want ID
}{
{name: "daily", want: DailyToday},
{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,tomorrow,hourly,three-day,weekend,storm" {
t.Fatalf("CommandNames() = %s, want stable command names", names)
}
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: DailyToday},
{key: "daily_today", want: DailyToday},
{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)
}
}
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 TestMorningBatchReportOrder(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_today,three_day,weekend" {
t.Fatalf("ids = %v, want morning report order", ids)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {