8 Commits

40 changed files with 1776 additions and 223 deletions

View File

@@ -39,15 +39,15 @@ Implemented `generate` commands write a JSON module snapshot, YAML data package,
preflight artifact, managed Markdown report, and metadata under the configured preflight artifact, managed Markdown report, and metadata under the configured
workspace. `--out` writes an extra Markdown copy for the operator; distributor workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate notification uses the managed report path, not the extra copy. `generate
hourly` covers the next six hours in the effective report timezone, does not tomorrow` and `generate hourly` write managed generated-text artifacts,
accept date or event window flags, writes managed generated-text artifacts, validate structured text from Scriptorium, and render the managed Markdown
validates the structured text, and renders the managed Markdown report from the report from embedded templates. `generate hourly` covers the next six hours in
embedded hourly template. the effective report timezone and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and `generate storm` requires explicit event-window bounds with `--start` and
`--end`. `--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Report. Batch
runs continue independent reports after a failure, print a JSON summary to runs continue independent reports after a failure, print a JSON summary to
stdout, write compact status lines to stderr, and return nonzero when any report stdout, write compact status lines to stderr, and return nonzero when any report
failed. `--out-dir` writes extra Markdown copies for the operator; distributor failed. `--out-dir` writes extra Markdown copies for the operator; distributor

View File

@@ -182,8 +182,8 @@ snapshot exists and a threshold is crossed.
report definitions. Omit a report entry to use its default module order. report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`, Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
`weekend`, and `storm`. Canonical report IDs such as `daily_today` and `weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
`daily_tomorrow` are also accepted. accepted.
Each report entry supports: Each report entry supports:

View File

@@ -88,10 +88,11 @@ recording, with the output path pointing at the raw generated-text JSON
artifact. artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the `weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. Markdown report artifacts are written by Scriptorium to preflight save point. For direct Markdown reports, Scriptorium writes the
the `--out` path. Generated-text raw JSON artifacts are also written by managed Markdown artifact to the `--out` path. For generated-text-template
Scriptorium to the `--out` path; later weatherreporter workflow steps validate reports, Scriptorium writes raw JSON to the `--out` path; later
and render those bytes. weatherreporter workflow steps validate those bytes and render Markdown from an
embedded template.
## Failure Behavior ## Failure Behavior

View File

@@ -135,7 +135,7 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except `run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
from report definitions. Batch generation continues independent reports after a from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a failure, records each result, writes compact status lines to stderr, emits a
JSON summary to stdout, and returns an aggregate error when any report failed. JSON summary to stdout, and returns an aggregate error when any report failed.

View File

@@ -38,6 +38,10 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped. module IDs fail validation instead of being skipped.
Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template.
Hourly Report supports source and valid-period modules that operate over its Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`, rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`, `precip_timing`, `alert_digest`, `spc_convective_outlooks`,

View File

@@ -7,21 +7,23 @@ This document describes structured generated-text handling in
`internal/generatedtext` validates structured text returned for `internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for generated-text-template reports and builds curated render contexts for
templates. The first implemented contract is the Hourly Report. templates. The implemented contracts are Tomorrow Report and Hourly Report.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- raw Hourly Report GeneratedText JSON - raw GeneratedText JSON for Tomorrow Report or Hourly Report
- report metadata from `internal/briefing` - report metadata from `internal/briefing`
- a module snapshot from `internal/module` - a module snapshot from `internal/module`
- validated hourly generated text - validated generated text
Outputs: Outputs:
- typed `Tomorrow` generated text
- typed `Hourly` generated text - typed `Hourly` generated text
- normalized stable JSON for validated hourly generated text - normalized stable JSON for validated generated text
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate` - typed `HourlyRenderContext` values for `internal/reporttemplate`
The hourly generated text JSON accepts: The hourly generated text JSON accepts:
@@ -39,6 +41,22 @@ The hourly generated text JSON accepts:
`precipitation_timing` and `confidence` are optional and omitted from normalized `precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank. JSON when blank.
The Tomorrow generated text JSON accepts:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` is required after trimming whitespace. `forecast_discussion` must
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
## Boundaries ## Boundaries
- This package owns typed generated-text validation and render-context shaping. - This package owns typed generated-text validation and render-context shaping.
@@ -54,7 +72,8 @@ JSON when blank.
- Malformed generated-text JSON fails with decode context. - Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding. - Unknown generated-text JSON fields fail during decoding.
- Empty required hourly fields fail after trimming whitespace. - Empty required fields fail after trimming whitespace.
- Tomorrow forecast discussion fails when no nonblank paragraphs remain.
- Missing optional render-context stanzas become nil module pointers. - Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time, - Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering. or invalid valid period, fails before template rendering.
@@ -64,6 +83,7 @@ JSON when blank.
Inspect: Inspect:
- `internal/generatedtext/hourly_test.go` - `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/render_context_test.go` - `internal/generatedtext/render_context_test.go`
## Invariants ## Invariants

View File

@@ -47,6 +47,28 @@ The registry recognizes these IDs:
Every registered module has a builder. Report composition entries that refer to Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped. unknown or unimplemented module IDs fail validation instead of being skipped.
## Tomorrow Composition
The default Tomorrow Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `tomorrow_planning`
14. `hourly_forecast`
The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Hourly Composition ## Hourly Composition
The default Hourly Report module order is: The default Hourly Report module order is:

View File

@@ -68,8 +68,9 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured labels; canonical report metadata and source timestamps remain structured
timestamps where applicable. timestamps where applicable.
Hourly Report module snapshots use the same package schema and categories when Tomorrow Report and Hourly Report module snapshots use the same package schema
converted into prompt input. The default hourly module list places and categories when converted into prompt input. The default hourly module list
places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under `precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under `applicable_risk_products`, AFD/SPC discussion/weather story under
`narrative_products`, and current/hourly data under `raw_data`. It does not `narrative_products`, and current/hourly data under `raw_data`. It does not
@@ -77,6 +78,10 @@ include civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data are produced later in app orchestration and are not part of the YAML data
package. package.
The default Tomorrow module list includes civil-day summary stanzas,
`tomorrow_planning`, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium.
Current categories are: Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks, - `applicable_risk_products`: location-applicable alerts, warnings, outlooks,

View File

@@ -27,17 +27,17 @@ Each report definition declares:
- default ordered module composition - default ordered module composition
Markdown report definitions use the `scriptorium_markdown` generation mode. Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Hourly Report Their template and structured-text schema identifiers are empty. Tomorrow
declares `generated_text_template` with template ID `hourly` and schema ID Report and Hourly Report declare `generated_text_template`; the app uses their
`hourly`; the app uses those identifiers to validate generated text and render template and schema identifiers to validate generated text and render embedded
the embedded hourly template. Markdown templates.
## Reports ## Reports
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility | | Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today, Daily Tomorrow | | Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow | | Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook | | 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook | | Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
@@ -49,7 +49,7 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil - Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied. day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time. - Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in - Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant, the effective report timezone. The duration is an internal report constant,
not a configuration field. not a configuration field.
@@ -76,12 +76,13 @@ IDs, then uses the registry for report policy.
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report output path copying uses batch output names from report definitions. Report
module overrides can use the `hourly` key for Hourly Report. module overrides can use short keys such as `tomorrow` and `hourly`, or
canonical report IDs such as `daily_today`.
## Batch Membership ## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Daily Tomorrow. Hourly Report is not part on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
of a scheduled batch. of a scheduled batch.
## State And App Usage ## State And App Usage
@@ -110,7 +111,9 @@ Inspect:
## Invariants ## Invariants
- Report selection goes through the registry. - Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_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.
- Valid periods are half-open intervals independent of rendered report text. - Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy default module composition, comparison compatibility, and comparison strategy

View File

@@ -6,8 +6,8 @@ in `internal/reporttemplate`.
## Purpose ## Purpose
`internal/reporttemplate` owns repository-native report templates and companion `internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The first implemented template contract is the GeneratedText JSON schemas. The implemented template contracts are Tomorrow
Hourly Report. Report and Hourly Report.
The package embeds assets from: The package embeds assets from:
@@ -27,8 +27,9 @@ Outputs:
- GeneratedText schema bytes for prompt/schema configuration - GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist - rendered Markdown bytes for app orchestration to persist
The implemented template ID is `hourly`. The implemented schema ID is also The implemented template IDs are `tomorrow` and `hourly`. The implemented
`hourly`, backed by `hourly.generated_text.schema.json`. schema IDs are also `tomorrow` and `hourly`, backed by
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
## Boundaries ## Boundaries
@@ -41,31 +42,37 @@ GeneratedText validation is owned by `internal/generatedtext`. App
orchestration decides which template and schema IDs apply to a report through orchestration decides which template and schema IDs apply to a report through
`internal/report` definitions. `internal/report` definitions.
## Template Contract ## Template Contracts
Hourly rendering uses a typed render context with: Tomorrow and Hourly rendering use typed render contexts with:
- report metadata labels such as title, location, valid period, and generation - report metadata labels such as title, location, valid period, and generation
time time
- validated hourly GeneratedText prose slots - validated GeneratedText prose slots
- deterministic labels derived from module outputs, including current - deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks, conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story forecast discussion, SPC discussion, and weather story
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
forecast discussion generated-text slot. The ordered daypart slice is built in
Go so templates do not range over maps.
Templates use `text/template` with `missingkey=error`, so missing context fields Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown. fail rendering instead of producing incomplete Markdown.
## Schema Contract ## Schema Contract
The hourly GeneratedText schema describes the structured prose Scriptorium is The GeneratedText schemas describe the structured prose Scriptorium is expected
expected to write for the prompt. It requires: to write for each generated-text prompt. Hourly requires:
- `summary` - `summary`
- `forecast_discussion` - `forecast_discussion`
It allows optional `precipitation_timing` and `confidence`, and rejects Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
additional properties. Weather truth remains in module outputs; GeneratedText is schemas allow optional `precipitation_timing` and `confidence`, and reject
limited to prose slots consumed by the template. additional properties. Weather truth remains in module outputs; GeneratedText
is limited to prose slots consumed by the template.
## Failure Behavior ## Failure Behavior

View File

@@ -91,7 +91,9 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the selects the latest earlier snapshot whose report ID is compatible with the
current report definition. current report definition.
- Daily Today and Daily Tomorrow are compatible with each other for the same - Daily Today compares with prior Daily Today snapshots for the same valid
local date.
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
valid local date. valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local - 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date. date.

View File

@@ -17,23 +17,23 @@ weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
``` ```
Markdown-path generation commands resolve a report period, fetch a Weather API Generation commands resolve a report period, fetch a Weather API bundle, build
bundle, build a JSON module snapshot, build a YAML prompt input data package, a JSON module snapshot, build a YAML prompt input data package, run
run `scriptorium render`, run `scriptorium run`, and write managed artifacts `scriptorium render`, and write managed artifacts under the configured
under the configured workspace. When distributor notification is enabled, workspace. Markdown-path reports then run `scriptorium run` directly to the
weatherreporter uploads the managed Markdown report after report rendering managed Markdown report path.
succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
copy for generated reports; it is not used as the distributor upload source.
`generate hourly` covers the six-hour rolling period from generation time in `generate tomorrow` and `generate hourly` use the generated-text-template
the effective report timezone and is not part of scheduled morning or evening workflow. They run structured `scriptorium run` to raw GeneratedText JSON,
batches. It builds the same module snapshot and data package, runs validate the structured text, save a render context, and render the managed
`scriptorium render` as preflight, runs structured `scriptorium run` to raw Markdown report from embedded templates. `generate hourly` covers the six-hour
GeneratedText JSON, validates the structured text, saves a render context, and rolling period from generation time in the effective report timezone and is not
renders the managed Markdown report from the embedded hourly template. When part of scheduled morning or evening batches.
distributor notification is enabled, hourly uploads the managed Markdown report
after final metadata is saved. `--out PATH` writes an extra Markdown copy and When distributor notification is enabled, weatherreporter uploads the managed
is not used as the distributor upload source. Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source.
Batch commands: Batch commands:
@@ -43,7 +43,7 @@ weatherreporter run evening
``` ```
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Report. Batch
commands print a JSON summary to stdout, write compact per-report status lines commands print a JSON summary to stdout, write compact per-report status lines
to stderr, continue independent reports after one report fails, and return to stderr, continue independent reports after one report fails, and return
nonzero when any report failed. When notification is configured, the summary and nonzero when any report failed. When notification is configured, the summary and
@@ -80,6 +80,14 @@ workspace/
<run_id>.generated_text.run.json <run_id>.generated_text.run.json
<run_id>.generated_text.json <run_id>.generated_text.json
<run_id>.render_context.json <run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -97,6 +105,9 @@ workspace/
hourly/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
@@ -113,6 +124,9 @@ workspace/
hourly/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
@@ -129,6 +143,9 @@ workspace/
hourly/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
@@ -141,6 +158,8 @@ workspace/
<run_id>.md <run_id>.md
hourly/ hourly/
<run_id>.md <run_id>.md
tomorrow/
<run_id>.md
storm/ storm/
<run_id>.md <run_id>.md
``` ```
@@ -165,7 +184,8 @@ Each generated report writes metadata that links:
- prompt input data package path - prompt input data package path
- preflight output path - preflight output path
- managed Markdown report path - managed Markdown report path
- generated text schema ID and generated-text artifact paths for Hourly Report - generated text schema ID and generated-text artifact paths for
generated-text-template reports
- distributor notification debug artifact path, when notification is attempted - distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification Batch summaries include report status, error text when applicable, notification
@@ -246,7 +266,8 @@ source provenance and warnings without dumping full weather payloads.
Recent Changes are computed from structured module snapshots, not rendered Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text. Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Today compares with prior Daily Today snapshots for the same valid local
date. Tomorrow Report compares with prior Tomorrow Report snapshots for the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Hourly Report and compatible Weekend snapshots for the same weekend window. Hourly Report and

View File

@@ -4,15 +4,17 @@
This guide describes the implemented Markdown report template surface for This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates, `weatherreporter`. It is for maintainers editing embedded report templates,
especially the hourly report template. especially generated-text-template reports.
Templates are Go `text/template` files. The current implemented template is: Templates are Go `text/template` files. The current implemented templates are:
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl` - `internal/reporttemplate/templates/hourly.md.tmpl`
The hourly template is rendered from a structured `HourlyRenderContext`. Weather Templates are rendered from structured contexts such as `TomorrowRenderContext`
data collection, derivation, module execution, generated text validation, and and `HourlyRenderContext`. Weather data collection, derivation, module
artifact paths are handled before template rendering. execution, generated text validation, and artifact paths are handled before
template rendering.
## Editing Rules ## Editing Rules
@@ -77,6 +79,41 @@ Example:
{{ .GeneratedText.ForecastDiscussion }} {{ .GeneratedText.ForecastDiscussion }}
``` ```
## Tomorrow Context
The Tomorrow template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Tomorrow generated text uses the same `.GeneratedText.Summary`,
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Modules ## Modules
`.Modules` exposes typed outputs from the same module pipeline used for the `.Modules` exposes typed outputs from the same module pipeline used for the

View File

@@ -185,6 +185,52 @@ prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md), Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md). [Scriptorium integration](integrations/scriptorium.md).
## Generated Text Validation Fails
Symptom: Tomorrow or Hourly generation fails with generated-text decode,
unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
Then inspect the generated-text raw path recorded in metadata, if present.
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
writes the expected structured JSON for the report.
Relevant docs: [Operations guide](operations.md),
[Generated Text internals](internal/generatedtext.md),
[Scriptorium integration](integrations/scriptorium.md).
## Template Rendering Fails
Symptom: Tomorrow or Hourly generation fails with report template parsing or
execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
If metadata records generated-text and render-context paths, inspect those
artifacts along with the template named by the report definition.
Safe fix: update the embedded template or render-context builder so the
template uses the implemented typed context.
Relevant docs: [Report Templates](templates.md),
[Report Template internals](internal/reporttemplate.md).
## Batch Command Returns Nonzero ## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero. Symptom: `run morning` or `run evening` returns nonzero.

View File

@@ -394,7 +394,7 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
case ReportDaily: case ReportDaily:
return report.DailyToday, nil return report.DailyToday, nil
case ReportTomorrow: case ReportTomorrow:
return report.DailyTomorrow, nil return report.Tomorrow, nil
case ReportHourly: case ReportHourly:
return report.Hourly, nil return report.Hourly, nil
case ReportThreeDay: case ReportThreeDay:
@@ -566,6 +566,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
metadata: metadata, metadata: metadata,
metadataPath: metadataPath, metadataPath: metadataPath,
preflightPath: preflightPath, preflightPath: preflightPath,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges, recentChanges: recentChanges,
renderResult: renderResult, renderResult: renderResult,
renderer: renderer, renderer: renderer,
@@ -646,6 +647,7 @@ type generatedReportRequest struct {
metadata state.Metadata metadata state.Metadata
metadataPath string metadataPath string
preflightPath string preflightPath string
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change recentChanges []changes.Change
renderResult *scriptorium.RenderResult renderResult *scriptorium.RenderResult
renderer Renderer renderer Renderer
@@ -678,7 +680,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err != nil { if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err) return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
} }
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText) generatedText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
if err != nil { if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err) return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
} }
@@ -692,7 +694,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
return nil, err return nil, err
} }
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, hourlyText) renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, generatedText)
if err != nil { if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err) return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
} }
@@ -755,6 +757,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
NotificationPath: notificationPath, NotificationPath: notificationPath,
Metadata: req.metadata, Metadata: req.metadata,
MetadataPath: metadataPath, MetadataPath: metadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, RenderResult: req.renderResult,
StructuredRunResult: structuredResult, StructuredRunResult: structuredResult,
@@ -1085,7 +1088,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch reportID { switch reportID {
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds) return changes.CompareThreeDay(previous, current, thresholds)
@@ -1110,19 +1113,31 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
} }
} }
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) { func validateGeneratedText(definition report.Definition, data []byte) (any, []byte, error) {
switch definition.GeneratedTextSchemaID { switch definition.GeneratedTextSchemaID {
case "hourly": case "hourly":
return generatedtext.ValidateHourly(data) return generatedtext.ValidateHourly(data)
case "tomorrow":
return generatedtext.ValidateTomorrow(data)
default: default:
return generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID) 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, hourly generatedtext.Hourly) (any, error) { func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, generated any) (any, error) {
switch definition.TemplateID { switch definition.TemplateID {
case "hourly": 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) 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: default:
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID) return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
} }

View File

@@ -16,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "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/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -645,6 +646,62 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
} }
} }
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, mustParse("2026-05-29T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
notifier := &recordingNotifier{}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTomorrowGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportID != report.Tomorrow {
t.Fatalf("notification ReportID = %q, want tomorrow", req.ReportID)
}
if req.PipelineID != "weatherreporter.tomorrow.tomorrow" {
t.Fatalf("PipelineID = %q, want report/artifact group values", req.PipelineID)
}
if req.BundleID != "tomorrow.tomorrow.md.tomorrow" {
t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID)
}
wantBundlePaths := []string{"2026-05-30/tomorrow/tomorrow.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.ReportPath != result.ReportPath {
t.Fatalf("ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
}
}
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) { func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{ renderer := &recordingRenderer{
@@ -795,6 +852,65 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *te
} }
} }
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)
}
}
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server) cfg := dailyTestConfig(t, server)
@@ -1226,9 +1342,9 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
renderer := &recordingRenderer{ renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0}, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
runBody: "# Tomorrow Planning Brief\n", structuredRunBody: validTomorrowGeneratedTextJSON(),
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
@@ -1239,10 +1355,22 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" { if result.Metadata.ReportID != report.Tomorrow || result.Metadata.Variant != "tomorrow" {
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant) t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
} }
if result.Metadata.GeneratedTextSchemaID != "tomorrow" || result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata generated-text links = %#v, want tomorrow generated-text artifacts", result.Metadata)
}
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary") dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
if err != nil { if err != nil {
t.Fatalf("decode daily summary: %v", err) t.Fatalf("decode daily summary: %v", err)
@@ -1253,12 +1381,31 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok { if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
t.Fatal("tomorrow_planning stanza missing") t.Fatal("tomorrow_planning stanza missing")
} }
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") { if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") {
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
} }
assertPathsExist(t, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
for _, want := range []string{`"Title": "Saturday's Weather"`, `"GeneratedText": {`, `"forecast_discussion": [`, `"Dayparts": [`} {
if !strings.Contains(string(renderContext), want) {
t.Fatalf("render context missing %q:\n%s", want, string(renderContext))
}
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{"# Saturday's Weather", "## Daypart Forecast", "## Forecast Discussion", "Tomorrow starts with showers before improving."} {
if !strings.Contains(string(reportData), want) {
t.Fatalf("tomorrow report missing %q:\n%s", want, string(reportData))
}
}
} }
func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -1270,8 +1417,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
} }
priorResolved, err := ResolveGenerate(GenerateRequest{ priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportDaily, Report: ReportTomorrow,
Date: mustParse("2026-05-30T12:00:00-05:00"),
}, mustParse("2026-05-29T17:00:00-05:00")) }, mustParse("2026-05-29T17:00:00-05:00"))
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
@@ -1286,9 +1432,9 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
t.Fatalf("ResolveGenerate(current) error = %v", err) t.Fatalf("ResolveGenerate(current) error = %v", err)
} }
renderer := &recordingRenderer{ renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0}, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
runBody: "# Tomorrow Planning Brief\n", structuredRunBody: validTomorrowGeneratedTextJSON(),
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
@@ -1301,10 +1447,54 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.PriorSnapshot == nil { if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot") t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot")
} }
if len(result.RecentChanges) == 0 { if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from compatible prior daily snapshot") t.Fatal("RecentChanges length = 0, want changes from compatible prior tomorrow snapshot")
}
}
func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, mustParse("2026-05-28T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved, 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(current) error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: currentResolved,
Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.PriorSnapshot != nil {
t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none from incompatible prior tomorrow snapshot", result.RecentChanges)
} }
} }
@@ -1622,8 +1812,8 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
{ {
name: "tomorrow", name: "tomorrow",
kind: ReportTomorrow, kind: ReportTomorrow,
wantID: report.DailyTomorrow, wantID: report.Tomorrow,
wantPrompt: "weather.daily_report", wantPrompt: "weather.tomorrow_generated_text",
wantStart: "2026-05-30T00:00:00-05:00", wantStart: "2026-05-30T00:00:00-05:00",
wantEnd: "2026-05-31T00:00:00-05:00", wantEnd: "2026-05-31T00:00:00-05:00",
requestDate: time.Time{}, requestDate: time.Time{},
@@ -2061,6 +2251,10 @@ func validHourlyGeneratedTextJSON() string {
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
} }
func validTomorrowGeneratedTextJSON() string {
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
}
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper() t.Helper()
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
@@ -2402,6 +2596,13 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++ r.structuredRunCalls++
body := validHourlyGeneratedTextJSON()
if req.PromptID == "weather.tomorrow_generated_text" {
body = validTomorrowGeneratedTextJSON()
}
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err
}
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
} }

View File

@@ -11,24 +11,29 @@ import (
) )
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"` DisplayName string `json:"display_name,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"` MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"` MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
Snow bool `json:"snow,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Ice bool `json:"ice,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
Fog bool `json:"fog,omitempty"` DominantCondition string `json:"dominant_condition,omitempty"`
Heat bool `json:"heat,omitempty"` DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
Cold bool `json:"cold,omitempty"` NotableConditions []string `json:"notable_conditions,omitempty"`
Wind bool `json:"wind,omitempty"` Snow bool `json:"snow,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"` Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
} }
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -46,24 +51,29 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature), PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), TempRangeF: rangeLabel(daypart.Temperature),
DominantCondition: daypart.DominantCondition, TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
NotableConditions: append([]string(nil), daypart.NotableConditions...), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
Snow: daypart.Indicators.Snow, DominantCondition: daypart.DominantCondition,
Ice: daypart.Indicators.Ice, DominantConditionLower: strings.ToLower(daypart.DominantCondition),
Fog: daypart.Indicators.Fog, NotableConditions: append([]string(nil), daypart.NotableConditions...),
Heat: daypart.Indicators.Heat, Snow: daypart.Indicators.Snow,
Cold: daypart.Indicators.Cold, Ice: daypart.Indicators.Ice,
Wind: daypart.Indicators.Wind, Fog: daypart.Indicators.Fog,
RelevantAlertCount: len(daypart.AlertOverlaps), Heat: daypart.Indicators.Heat,
Cold: daypart.Indicators.Cold,
Wind: daypart.Indicators.Wind,
RelevantAlertCount: len(daypart.AlertOverlaps),
} }
if daypart.MaxPrecipitationProbability != nil { if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
} }
if daypart.PeakWindGust != nil { if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
@@ -72,6 +82,53 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
return value return value
} }
func temperaturePhraseF(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
if value.Min != nil && value.Max != nil {
low := roundedInt(value.Min)
high := roundedInt(value.Max)
if low == nil || high == nil {
return ""
}
lowPhrase := temperatureBandPhrase(*low)
highPhrase := temperatureBandPhrase(*high)
if lowPhrase == highPhrase {
return lowPhrase
}
return lowPhrase + " to " + highPhrase
}
if value.Min != nil {
low := roundedInt(value.Min)
if low == nil {
return ""
}
return temperatureBandPhrase(*low)
}
high := roundedInt(value.Max)
if high == nil {
return ""
}
return temperatureBandPhrase(*high)
}
func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
qualifier := "mid"
switch {
case remainder <= 3:
qualifier = "low"
case remainder >= 7:
qualifier = "upper"
}
return fmt.Sprintf("%s %ds", qualifier, decade)
}
func multipleSummaryDates(summaries []forecast.DailySummary) bool { func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, summary := range summaries { for _, summary := range summaries {

View File

@@ -169,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.TemperaturePhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
}
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" { if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds) t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
} }
overnight := value["overnight"]
if overnight.MentionPrecipitation {
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
}
afternoon := value["afternoon"] afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
} }
overnight := value["overnight"]
if !overnight.Cold { if !overnight.Cold {
t.Fatalf("overnight = %#v, want cold hazard", overnight) t.Fatalf("overnight = %#v, want cold hazard", overnight)
} }
@@ -185,7 +191,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
@@ -195,9 +201,50 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
} }
} }
func TestTemperaturePhraseF(t *testing.T) {
tests := []struct {
name string
value forecast.Range
want string
}{
{
name: "single low band",
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
want: "low 70s",
},
{
name: "single upper value",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
want: "upper 60s",
},
{
name: "range across bands",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
want: "upper 60s to mid 70s",
},
{
name: "max only",
value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s",
},
{
name: "empty",
value: forecast.Range{},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := temperaturePhraseF(test.value); got != test.want {
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
}
})
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyTomorrow) ctx := derivedModuleContext(report.Tomorrow)
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil { if err != nil {

View File

@@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
ID: module.Metadata, ID: module.Metadata,
@@ -276,7 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.NarrativeForecastOptions{}, DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule, Builder: buildNarrativeForecastModule,
}, },
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{}, DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly}, SupportedReports: []report.ID{report.DailyToday, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
}, },
@@ -295,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
StanzaName: "derived_daily_summary", StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{}, DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule, Builder: buildDerivedDailySummaryModule,
}, },
@@ -379,7 +379,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
StanzaName: "tomorrow_planning", StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{}, DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.DailyTomorrow}, SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule, Builder: buildTomorrowPlanningModule,
}, },

View File

@@ -187,7 +187,7 @@ func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.DailyToday:
return "today" return "today"
case report.DailyTomorrow: case report.Tomorrow:
return "tomorrow" return "tomorrow"
default: default:
return "" return ""

View File

@@ -130,22 +130,22 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read report: %v", err) t.Fatalf("read report: %v", err)
} }
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Saturday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml") dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") { 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)) t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
} }
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
if err != nil { if err != nil {
t.Fatalf("glob managed report: %v", err) t.Fatalf("glob managed report: %v", err)
} }
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches) t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
} }
} }
@@ -171,18 +171,18 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
if len(dataPackageMatches) != 1 { if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches) t.Fatalf("data package files = %#v, want one", dataPackageMatches)
} }
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
if err != nil { if err != nil {
t.Fatalf("glob managed report: %v", err) t.Fatalf("glob managed report: %v", err)
} }
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches) t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
} }
} }
@@ -482,10 +482,10 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/runs/distributor-run-1" { if r.URL.Path == "/runs/distributor-run-1" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.daily","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`)) _, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
return return
} }
if r.URL.Path != "/v1/pipelines/weatherreporter.daily/upload" { if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -521,7 +521,7 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
if len(summary.Reports) != 1 { if len(summary.Reports) != 1 {
t.Fatalf("reports = %#v, want one report", summary.Reports) t.Fatalf("reports = %#v, want one report", summary.Reports)
} }
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.daily" { if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" {
t.Fatalf("notification fields = %#v", summary.Reports[0]) t.Fatalf("notification fields = %#v", summary.Reports[0])
} }
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) { if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
@@ -1176,13 +1176,31 @@ if [ "$1" = "render" ]; then
fi fi
if [ "$1" = "run" ]; then if [ "$1" = "run" ]; then
out="" out=""
prompt=""
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then if [ "$1" = "--out" ]; then
shift shift
out="$1" out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi fi
shift shift
done done
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Tomorrow starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
printf 'wrote report\n' >&2 printf 'wrote report\n' >&2
exit 0 exit 0
@@ -1225,6 +1243,20 @@ if [ "$1" = "run" ]; then
"precipitation_timing": "A cold front is moving into the region.", "precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium" "confidence": "Medium"
} }
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Tomorrow starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON JSON
printf 'wrote generated text\n' >&2 printf 'wrote generated text\n' >&2
exit 0 exit 0

View File

@@ -262,6 +262,16 @@ reports:
`, `,
wantErr: `unknown module "forecast_delta"`, wantErr: `unknown module "forecast_delta"`,
}, },
{
name: "RetiredTomorrowReportID",
yaml: `
reports:
daily_tomorrow:
deterministic_modules:
- metadata
`,
wantErr: "reports.daily_tomorrow is not a known report",
},
{ {
name: "InvalidOptions", name: "InvalidOptions",
yaml: ` yaml: `

View File

@@ -111,8 +111,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
switch normalized { switch normalized {
case "daily", "daily_today": case "daily", "daily_today":
return report.DailyToday, nil return report.DailyToday, nil
case "tomorrow", "daily_tomorrow": case "tomorrow":
return report.DailyTomorrow, nil return report.Tomorrow, nil
case "hourly": case "hourly":
return report.Hourly, nil return report.Hourly, nil
case "three_day", "three_day_outlook": case "three_day", "three_day_outlook":

View File

@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.Hourly: case report.Hourly:
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.Tomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil { if err != nil {
return DerivedFacts{}, err return DerivedFacts{}, err

View File

@@ -135,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) { func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation() location := testLocation()
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} { for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
@@ -259,7 +259,7 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
}, },
{ {
name: "daily tomorrow", name: "daily tomorrow",
resolved: resolveForTest(t, report.DailyTomorrow, now, location), resolved: resolveForTest(t, report.Tomorrow, now, location),
wantOutlookIDs: []string{"sat-enhanced"}, wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"}, wantDiscussion: []string{"day2"},
}, },

View File

@@ -2,10 +2,14 @@ package generatedtext
import ( import (
"fmt" "fmt"
"sort"
"strings"
"time" "time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -40,6 +44,46 @@ type HourlyTemplateModules struct {
WeatherStory *briefing.WeatherStoryModule WeatherStory *briefing.WeatherStoryModule
} }
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TomorrowReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TomorrowTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TomorrowDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TomorrowPlanning *briefing.TomorrowPlanningModule
}
type TomorrowDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil { if err != nil {
@@ -72,6 +116,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}, nil }, nil
} }
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
}
modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil {
return TomorrowRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return TomorrowRenderContext{
Report: TomorrowReportContext{
Title: forecastDayName + "'s Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
if err != nil { if err != nil {
@@ -122,6 +201,72 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
}, nil }, nil
} }
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
if err != nil {
return TomorrowTemplateModules{}, err
}
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning))
if err != nil {
return TomorrowTemplateModules{}, err
}
return TomorrowTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
TomorrowPlanning: planning,
}, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) { func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
output, ok := snapshot.LookupStanza(name) output, ok := snapshot.LookupStanza(name)
if !ok || output.Value == nil { if !ok || output.Value == nil {
@@ -129,11 +274,74 @@ func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
} }
value, _, err := module.StanzaValue[T](snapshot, name) value, _, err := module.StanzaValue[T](snapshot, name)
if err != nil { if err != nil {
return nil, fmt.Errorf("build hourly render context: %w", err) return nil, fmt.Errorf("build render context: %w", err)
} }
return &value, nil return &value, nil
} }
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
if dayparts == nil || len(*dayparts) == 0 {
return nil
}
out := make([]TomorrowDaypartContext, 0, len(*dayparts))
seen := map[string]struct{}{}
for _, daypart := range ordered {
for _, key := range daypartModuleKeyCandidates(daypart) {
value, ok := (*dayparts)[key]
if !ok {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, TomorrowDaypartContext{Key: key, Summary: value})
break
}
}
var remaining []string
for key := range *dayparts {
if _, ok := seen[key]; !ok {
remaining = append(remaining, key)
}
}
sort.Strings(remaining)
for _, key := range remaining {
out = append(out, TomorrowDaypartContext{Key: key, Summary: (*dayparts)[key]})
}
return out
}
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
key := normalizedDaypartKey(daypart.Name)
if key == "" {
key = "unnamed"
}
if daypart.Period.Start.IsZero() {
return []string{key}
}
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
}
func normalizedDaypartKey(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
var out strings.Builder
lastUnderscore := false
for _, r := range lower {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
out.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(out.String(), "_")
}
func locationName(metadata briefing.Metadata) string { func locationName(metadata briefing.Metadata) string {
if metadata.Location != nil { if metadata.Location != nil {
if metadata.Location.Name != "" && metadata.Location.Region != "" { if metadata.Location.Name != "" && metadata.Location.Region != "" {

View File

@@ -114,6 +114,120 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
} }
} }
func TestBuildTomorrowRenderContext(t *testing.T) {
metadata := testTomorrowMetadata()
snapshot := testTomorrowSnapshot(t)
generated := Tomorrow{
Summary: "Tomorrow starts quiet, then showers become more likely later in the day.",
ForecastDiscussion: []string{
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
},
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
}
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if ctx.Report.Title != "Monday's Weather" {
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
}
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
}
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
}
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
}
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
}
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
}
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
}
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
}
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("tomorrow", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Monday's Weather",
"**Forecast date:** Monday, June 15, 2026",
"Tomorrow starts quiet, then showers become more likely later in the day.",
"- **Morning:** low 60s and partly cloudy.",
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.",
"## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.",
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Monday's Weather",
"## Daypart Forecast",
"- **Morning:**",
"- **Afternoon:**",
"## Precipitation Timing",
"## Forecast Discussion",
})
}
func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
Summary: "Dry weather is expected tomorrow.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if 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)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
if strings.Contains(text, "## Precipitation Timing") {
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
}
if !strings.Contains(text, "- No daypart forecast details are available.") {
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
}
}
func testMetadata() briefing.Metadata { func testMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.Metadata{
@@ -134,6 +248,26 @@ func testMetadata() briefing.Metadata {
} }
} }
func testTomorrowMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-tomorrow",
ReportID: report.Tomorrow,
PromptID: "weather.tomorrow_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testCollected() facts.CollectedFacts { func testCollected() facts.CollectedFacts {
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)} return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
} }
@@ -142,6 +276,29 @@ func testDerived() facts.DerivedFacts {
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}} return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
} }
func testTomorrowDerived() facts.DerivedFacts {
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
return facts.DerivedFacts{
DaypartSummaries: []forecast.DaypartSummary{
{
Name: "morning",
Period: timeutil.Period{
Start: morningStart,
End: afternoonStart,
},
},
{
Name: "afternoon",
Period: timeutil.Period{
Start: afternoonStart,
End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC),
},
},
},
}
}
func testSnapshot(t *testing.T) module.Snapshot { func testSnapshot(t *testing.T) module.Snapshot {
t.Helper() t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{ snapshot, err := module.NewSnapshot([]module.Output{
@@ -273,6 +430,129 @@ func testSnapshot(t *testing.T) module.Snapshot {
return snapshot return snapshot
} }
func testTomorrowSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.Metadata,
StanzaName: string(module.Metadata),
Value: briefing.MetadataModule{
RunID: "run-tomorrow",
ReportID: report.Tomorrow,
PromptID: "weather.tomorrow_generated_text",
GeneratedAt: testTomorrowMetadata().GeneratedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testTomorrowMetadata().ValidPeriod,
},
},
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionTextLower: "clear",
TemperatureF: intPtr(58),
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
},
},
},
{
ID: module.DerivedDailySummary,
StanzaName: string(module.DerivedDailySummary),
Value: briefing.DerivedDailySummaryModule{
Date: "Monday, June 15, 2026",
HighTempF: intPtr(74),
LowTempF: intPtr(61),
DailyPrecipitationProbability: intPtr(70),
},
},
{
ID: module.DerivedDaypartSummaries,
StanzaName: string(module.DerivedDaypartSummaries),
Value: map[string]briefing.DerivedDaypartSummaryModule{
"afternoon": {
DisplayName: "Afternoon",
TemperaturePhraseF: "mid 70s",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
"morning": {
DisplayName: "Morning",
TemperaturePhraseF: "low 60s",
DominantConditionLower: "partly cloudy",
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
},
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Rain chances increase late."},
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "convective outlook",
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Rain returns",
},
},
{
ID: module.TomorrowPlanning,
StanzaName: string(module.TomorrowPlanning),
Value: briefing.TomorrowPlanningModule{
MorningReadiness: []string{"Take sunglasses early."},
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func floatPtr(value float64) *float64 { func floatPtr(value float64) *float64 {
return &value return &value
} }
@@ -280,3 +560,18 @@ func floatPtr(value float64) *float64 {
func intPtr(value int) *int { func intPtr(value int) *int {
return &value return &value
} }
func assertOrderedText(t *testing.T, text string, ordered []string) {
t.Helper()
previousIndex := -1
for _, want := range ordered {
index := strings.Index(text, want)
if index < 0 {
t.Fatalf("text missing %q:\n%s", want, text)
}
if index <= previousIndex {
t.Fatalf("%q appears out of order in:\n%s", want, text)
}
previousIndex = index
}
}

View File

@@ -0,0 +1,62 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
type Tomorrow struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
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.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)
if err != nil {
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err)
}
return value, normalized, nil
}
func trimNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateTomorrow([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [
" A front will keep showers in the forecast. ",
"",
" Temperatures stay seasonable by afternoon. "
],
"precipitation_timing": " Rain is most likely before sunrise. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateTomorrow() error = %v", err)
}
if value.Summary != "Storms become more likely tomorrow." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateTomorrow([]byte(`{
"summary": "Storms become more likely tomorrow.",
"forecast_discussion": ["A front will keep showers in the forecast."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateTomorrow() error = %v", err)
}
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode tomorrow generated text",
},
{
name: "unknown field",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Storms become more likely tomorrow."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateTomorrow([]byte(test.in))
if err == nil {
t.Fatal("ValidateTomorrow() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -15,27 +15,29 @@ func dailyTodayDefinition() Definition {
ArtifactGroup: "daily", ArtifactGroup: "daily",
BatchOutputName: "daily.md", BatchOutputName: "daily.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, CompatiblePriorIDs: []ID{DailyToday},
Modules: dailyTodayModules(), Modules: dailyTodayModules(),
Morning: true, Morning: true,
resolve: resolveDailyToday, resolve: resolveDailyToday,
} }
} }
func dailyTomorrowDefinition() Definition { func tomorrowDefinition() Definition {
return Definition{ return Definition{
ID: DailyTomorrow, ID: Tomorrow,
Name: "Tomorrow Planning Brief", Name: "Tomorrow Report",
PromptID: "weather.daily_report", PromptID: "weather.tomorrow_generated_text",
GenerationMode: GenerationModeScriptoriumMarkdown, GenerationMode: GenerationModeGeneratedTextTemplate,
ComparisonStrategy: CompareSameValidDate, TemplateID: "tomorrow",
ArtifactGroup: "daily", GeneratedTextSchemaID: "tomorrow",
BatchOutputName: "tomorrow.md", ComparisonStrategy: CompareSameValidDate,
Generated: true, ArtifactGroup: "tomorrow",
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, BatchOutputName: "tomorrow.md",
Modules: dailyTomorrowModules(), Generated: true,
Evening: true, CompatiblePriorIDs: []ID{Tomorrow},
resolve: resolveDailyTomorrow, Modules: tomorrowModules(),
Evening: true,
resolve: resolveTomorrow,
} }
} }
@@ -57,7 +59,7 @@ func dailyTodayModules() []module.ConfigItem {
) )
} }
func dailyTomorrowModules() []module.ConfigItem { func tomorrowModules() []module.ConfigItem {
return moduleItems( return moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -83,6 +85,6 @@ func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now, req.Location), nil return timeutil.CivilDay(req.Now, req.Location), nil
} }
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) { func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
} }

View File

@@ -12,12 +12,12 @@ import (
type ID string type ID string
const ( const (
DailyToday ID = "daily_today" DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow" Tomorrow ID = "tomorrow"
Hourly ID = "hourly" Hourly ID = "hourly"
ThreeDay ID = "three_day" ThreeDay ID = "three_day"
Weekend ID = "weekend" Weekend ID = "weekend"
Storm ID = "storm" Storm ID = "storm"
) )
type ComparisonStrategy string type ComparisonStrategy string

View File

@@ -32,7 +32,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
} }
return r.resolveIDs(ids, req) return r.resolveIDs(ids, req)
case Evening: case Evening:
return r.resolveIDs([]ID{DailyTomorrow}, req) return r.resolveIDs([]ID{Tomorrow}, req)
default: default:
return nil, fmt.Errorf("unknown batch %q", batch) return nil, fmt.Errorf("unknown batch %q", batch)
} }

View File

@@ -37,13 +37,22 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00") now := mustParse("2026-05-29T20:00:00-05:00")
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_report" { if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID) t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "tomorrow" {
t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "tomorrow" {
t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID)
} }
} }
@@ -207,8 +216,8 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
t.Fatalf("BatchReports() error = %v", err) t.Fatalf("BatchReports() error = %v", err)
} }
ids := resolvedIDs(resolved) ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_tomorrow" { if strings.Join(ids, ",") != "tomorrow" {
t.Fatalf("ids = %v, want daily_tomorrow", ids) t.Fatalf("ids = %v, want tomorrow", ids)
} }
} }
@@ -243,7 +252,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All()) ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "hourly", "three_day", "weekend", "storm"} want := []string{"daily_today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") { if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want) t.Fatalf("All() ids = %#v, want %#v", ids, want)
} }
@@ -268,15 +277,16 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
if !definition.Generated { if !definition.Generated {
continue continue
} }
if definition.ID == Hourly { if definition.ID == Hourly || definition.ID == Tomorrow {
wantTemplate := string(definition.ID)
if definition.GenerationMode != GenerationModeGeneratedTextTemplate { if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate) t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
} }
if definition.TemplateID != "hourly" { if definition.TemplateID != wantTemplate {
t.Fatalf("%s TemplateID = %q, want hourly", definition.ID, definition.TemplateID) t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate)
} }
if definition.GeneratedTextSchemaID != "hourly" { if definition.GeneratedTextSchemaID != wantTemplate {
t.Fatalf("%s GeneratedTextSchemaID = %q, want hourly", definition.ID, definition.GeneratedTextSchemaID) t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate)
} }
continue continue
} }
@@ -306,15 +316,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
artifactGroup: "daily", artifactGroup: "daily",
batchOutputName: "daily.md", batchOutputName: "daily.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{DailyToday},
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: DailyTomorrow, id: Tomorrow,
artifactGroup: "daily", artifactGroup: "tomorrow",
batchOutputName: "tomorrow.md", batchOutputName: "tomorrow.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{Tomorrow},
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{ {
@@ -406,7 +416,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
}, },
}, },
{ {
id: DailyTomorrow, id: Tomorrow,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,

View File

@@ -13,7 +13,7 @@ type Registry struct {
func DefaultRegistry() Registry { func DefaultRegistry() Registry {
definitions := []Definition{ definitions := []Definition{
dailyTodayDefinition(), dailyTodayDefinition(),
dailyTomorrowDefinition(), tomorrowDefinition(),
hourlyDefinition(), hourlyDefinition(),
threeDayDefinition(), threeDayDefinition(),
weekendDefinition(), weekendDefinition(),
@@ -74,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
} }
func (r Registry) All() []Definition { func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, Hourly, ThreeDay, Weekend, Storm} ids := []ID{DailyToday, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids)) out := make([]Definition, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if definition, ok := r.definitions[id]; ok { if definition, ok := r.definitions[id]; ok {

View File

@@ -0,0 +1,18 @@
# Tomorrow Generated Text
Create structured prose for the Tomorrow Report using the supplied
weatherreporter data package.
Return JSON that matches the Scriptorium-registered schema for
`weather.tomorrow_generated_text`.
Fields:
- `summary`: concise overview of tomorrow's weather.
- `forecast_discussion`: one or more paragraph strings explaining the main
weather drivers and forecast reasoning.
- `precipitation_timing`: optional plain-language precipitation timing context.
- `confidence`: optional uncertainty or confidence note.
Use deterministic facts from the data package for weather details. Do not
invent watches, warnings, precipitation windows, temperatures, or timing.

View File

@@ -12,11 +12,13 @@ import (
var assets embed.FS var assets embed.FS
var templates = map[string]string{ var templates = map[string]string{
"hourly": "templates/hourly.md.tmpl", "hourly": "templates/hourly.md.tmpl",
"tomorrow": "templates/tomorrow.md.tmpl",
} }
var schemas = map[string]string{ var schemas = map[string]string{
"hourly": "schemas/hourly.generated_text.schema.json", "hourly": "schemas/hourly.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
} }
func Template(id string) (string, error) { func Template(id string) (string, error) {

View File

@@ -18,11 +18,78 @@ func TestTemplateLookup(t *testing.T) {
} }
} }
func TestTomorrowTemplateLookup(t *testing.T) {
source, err := Template("tomorrow")
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
}
}
func TestSchemaLookup(t *testing.T) { func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly") data, err := Schema("hourly")
if err != nil { if err != nil {
t.Fatalf("Schema() error = %v", err) t.Fatalf("Schema() error = %v", err)
} }
assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"})
}
func TestTomorrowSchemaLookup(t *testing.T) {
data, err := Schema("tomorrow")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
schema := assertSchema(t, data, "summary,forecast_discussion")
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
if !ok {
t.Fatal("schema property forecast_discussion missing or invalid")
}
if property["type"] != "array" {
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
}
if property["minItems"] != float64(1) {
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
}
items, ok := property["items"].(map[string]any)
if !ok || items["type"] != "string" {
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
}
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
t.Helper()
schema := assertSchema(t, data, required)
for _, field := range fields {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func assertSchema(t *testing.T, data []byte, required string) struct {
Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
} {
t.Helper()
var schema struct { var schema struct {
Type string `json:"type"` Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"` AdditionalProperties bool `json:"additionalProperties"`
@@ -38,18 +105,10 @@ func TestSchemaLookup(t *testing.T) {
if schema.AdditionalProperties { if schema.AdditionalProperties {
t.Fatal("additionalProperties = true, want false") t.Fatal("additionalProperties = true, want false")
} }
if strings.Join(schema.Required, ",") != "summary,forecast_discussion" { if strings.Join(schema.Required, ",") != required {
t.Fatalf("required = %#v, want summary/forecast_discussion", schema.Required) t.Fatalf("required = %#v, want %s", schema.Required, required)
}
for _, field := range []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
} }
return schema
} }
func TestRenderHourly(t *testing.T) { func TestRenderHourly(t *testing.T) {
@@ -144,6 +203,119 @@ func TestRenderHourly(t *testing.T) {
}) })
} }
func TestRenderTomorrow(t *testing.T) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{
Title: "Monday's Weather",
ForecastDateLabel: "Monday, June 15, 2026",
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
},
GeneratedText: testTomorrowGeneratedText{
Summary: "Tomorrow starts dry before showers return later in the day.",
ForecastDiscussion: []string{
"Clouds increase after sunrise.",
"Rain chances peak during the afternoon.",
},
PrecipitationTiming: "A few showers may linger into early evening.",
},
Modules: testTomorrowModules{
Dayparts: []testTomorrowDaypart{
{
Key: "morning",
Summary: testDaypartSummary{
DisplayName: "Morning",
TemperaturePhraseF: "low 60s",
DominantConditionLower: "partly cloudy",
},
},
{
Key: "afternoon",
Summary: testDaypartSummary{
DisplayName: "Afternoon",
TemperaturePhraseF: "mid 70s",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
},
},
PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
},
},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Monday's Weather",
"**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.",
"- **Morning:** low 60s and partly cloudy.",
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.",
"Clouds increase after sunrise.",
"Rain chances peak during the afternoon.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Monday's Weather",
"## Daypart Forecast",
"- **Morning:**",
"- **Afternoon:**",
"## Precipitation Timing",
"## Forecast Discussion",
"Clouds increase after sunrise.",
"Rain chances peak during the afternoon.",
})
}
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{
Title: "Monday's Weather",
ForecastDateLabel: "Monday, June 15, 2026",
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
},
GeneratedText: testTomorrowGeneratedText{
Summary: "Dry weather is expected tomorrow.",
ForecastDiscussion: []string{"High pressure keeps rain chances low."},
PrecipitationTiming: "Any stray shower chance is too low to highlight.",
},
Modules: testTomorrowModules{
Dayparts: []testTomorrowDaypart{
{
Key: "morning",
Summary: testDaypartSummary{
DisplayName: "Morning",
TemperaturePhraseF: "low 60s",
DominantConditionLower: "clear",
},
},
},
PrecipTiming: &testPrecipTiming{},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, unwanted := range []string{"## Precipitation Timing", "Any stray shower chance is too low to highlight."} {
if strings.Contains(text, unwanted) {
t.Fatalf("dry render includes %q:\n%s", unwanted, text)
}
}
}
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{ rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{ Report: testReportContext{
@@ -209,6 +381,12 @@ type testRenderContext struct {
Modules testModules Modules testModules
} }
type testTomorrowRenderContext struct {
Report testTomorrowReportContext
GeneratedText testTomorrowGeneratedText
Modules testTomorrowModules
}
type testReportContext struct { type testReportContext struct {
Title string Title string
LocationName string LocationName string
@@ -223,6 +401,19 @@ type testGeneratedText struct {
Confidence string Confidence string
} }
type testTomorrowReportContext struct {
Title string
ForecastDateLabel string
GeneratedAtLabel string
}
type testTomorrowGeneratedText struct {
Summary string
ForecastDiscussion []string
PrecipitationTiming string
Confidence string
}
type testModules struct { type testModules struct {
CurrentConditions *testCurrentConditions CurrentConditions *testCurrentConditions
HourlyForecast *testHourlyForecast HourlyForecast *testHourlyForecast
@@ -234,6 +425,28 @@ type testModules struct {
WeatherStory *testWeatherStory WeatherStory *testWeatherStory
} }
type testTomorrowModules struct {
Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming
}
type testTomorrowDaypart struct {
Key string
Summary testDaypartSummary
}
type testDaypartSummary struct {
DisplayName string
TempRangeF string
TemperaturePhraseF string
MaxPopPercent *int
MaxPopTime string
MaxPopTimeLabel string
MentionPrecipitation bool
DominantCondition string
DominantConditionLower string
}
type testCurrentConditions struct { type testCurrentConditions struct {
ConditionText string ConditionText string
ConditionTextLower string ConditionTextLower string

View File

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

View File

@@ -0,0 +1,26 @@
# {{ .Report.Title }}
**Forecast date:** {{ .Report.ForecastDateLabel }}
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}
- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ if .Summary.TemperaturePhraseF }}{{ .Summary.TemperaturePhraseF }}{{ with .Summary.DominantConditionLower }} and {{ . }}{{ end }}{{ else }}{{ with .Summary.DominantConditionLower }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Precipitation chances peak at {{ . }}%{{ with $daypart.Summary.MaxPopTimeLabel }} at {{ . }}{{ else }}{{ with $daypart.Summary.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}{{ end }}
{{ end }}{{ else }}
- No daypart forecast details are available.
{{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ end }}

View File

@@ -42,30 +42,57 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") tests := []struct {
name string
paths, err := store.Paths(resolved) resolved report.Resolved
if err != nil { group string
t.Fatalf("Paths() error = %v", err) validDate string
runID string
}{
{
name: "hourly",
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
group: "hourly",
validDate: "2026-05-29",
runID: "20260529T100000.000000000Z_hourly",
},
{
name: "tomorrow",
resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"),
group: "tomorrow",
validDate: "2026-05-30",
runID: "20260529T230000.000000000Z_tomorrow",
},
} }
runID := "20260529T100000.000000000Z_hourly" for _, tt := range tests {
wants := map[string]string{ t.Run(tt.name, func(t *testing.T) {
"GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"), paths, err := store.Paths(tt.resolved)
"GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"), if err != nil {
"GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"), t.Fatalf("Paths() error = %v", err)
"RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"), }
} wants := map[string]string{
got := map[string]string{ "DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
"GeneratedTextRaw": paths.GeneratedTextRaw, "RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
"GeneratedTextResult": paths.GeneratedTextResult, "GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
"GeneratedText": paths.GeneratedText, "GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
"RenderContext": paths.RenderContext, "GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
} "RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
for name, want := range wants { }
if !strings.Contains(got[name], want) { got := map[string]string{
t.Fatalf("%s path = %q, want component %q", name, got[name], want) "DataPackage": paths.DataPackage,
} "RenderedReport": paths.RenderedReport,
"GeneratedTextRaw": paths.GeneratedTextRaw,
"GeneratedTextResult": paths.GeneratedTextResult,
"GeneratedText": paths.GeneratedText,
"RenderContext": paths.RenderContext,
}
for name, want := range wants {
if !strings.Contains(got[name], want) {
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
}
}
})
} }
} }
@@ -565,6 +592,26 @@ func resolveHourlyAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func resolveTomorrowAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{ return briefing.Metadata{
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,