Compare commits
10 Commits
ff2f8c16a3
...
7efd8b5855
| Author | SHA1 | Date | |
|---|---|---|---|
| 7efd8b5855 | |||
| 7cbc59d8a7 | |||
| 67b30dbad6 | |||
| cd8d77b37c | |||
| bb79232e3e | |||
| b4e0aadbef | |||
| 4fe0f40cef | |||
| 40b42f4bf3 | |||
| e8f1aa5caf | |||
| a02af0bce0 |
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
261
docs/roadmap/today.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
}, ¤t)
|
||||
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, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
b.bundle.Current = ¤t
|
||||
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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
155
internal/generatedtext/catalog.go
Normal file
155
internal/generatedtext/catalog.go
Normal 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)
|
||||
}
|
||||
164
internal/generatedtext/catalog_test.go
Normal file
164
internal/generatedtext/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
internal/generatedtext/json.go
Normal file
33
internal/generatedtext/json.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
86
internal/report/names.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user