8 Commits

50 changed files with 1692 additions and 275 deletions

View File

@@ -20,7 +20,7 @@ Markdown report after final metadata is saved.
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
@@ -40,17 +40,15 @@ weatherreporter inspect sources [--config PATH] RUN_ID
Implemented `generate` commands write a JSON module snapshot, YAML data package, 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 today`, notification uses the managed report path, not the extra copy. `generate daily`,
`generate tomorrow`, and `generate hourly` write managed generated-text artifacts, `generate today`, `generate tomorrow`, and `generate hourly` write managed
validate structured text from Scriptorium, and render the managed Markdown generated-text artifacts, validate structured text from Scriptorium, and render
report from embedded templates. `generate hourly` covers the next six hours in the managed Markdown report from embedded templates. `generate daily` requires
the effective report timezone and does not accept date or event window flags. `--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
`generate storm` requires explicit event-window bounds with `--start` and command error and stops before weather data is fetched. `generate hourly`
`--end`. covers the next six hours in the effective report timezone and does not accept
date or event window flags. `generate storm` requires explicit event-window
`generate daily` remains the existing Daily Today report. It is separate from bounds with `--start` and `--end`.
`generate today`; the commands use different report IDs, artifact groups,
prompts, and config override keys.
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Report. Batch except on Sunday. `run evening` generates the Tomorrow Report. Batch
@@ -76,7 +74,7 @@ They do not fetch weather data or invoke `scriptorium`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`. - `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command. - `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`. - `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily` and `generate today`; defaults to the current local date in the configured timezone. - `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`. - `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit. - `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
@@ -110,7 +108,7 @@ weatherreporter inspect modules 20260529T100000.000000000Z_today
weatherreporter inspect data-package 20260529T100000.000000000Z_today weatherreporter inspect data-package 20260529T100000.000000000Z_today
weatherreporter inspect prior 20260529T100000.000000000Z_today weatherreporter inspect prior 20260529T100000.000000000Z_today
weatherreporter inspect sources 20260529T100000.000000000Z_today weatherreporter inspect sources 20260529T100000.000000000Z_today
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today weatherreporter inspect metadata 20260529T100000.000000000Z_daily
``` ```
`inspect reports` lists recent generated runs with artifact paths and source `inspect reports` lists recent generated runs with artifact paths and source

View File

@@ -183,12 +183,12 @@ report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`, Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases `three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
are also valid, including `daily_today`, `three_day_outlook`, are also valid, including `three_day_outlook`, `weekend_outlook`, and
`weekend_outlook`, and `storm_report`. Hyphens and underscores are treated `storm_report`. Hyphens and underscores are treated equivalently in report
equivalently in report keys. keys. Retired report keys are not supported.
`reports.today` applies only to the Today Report. `reports.daily` and `reports.today` applies only to the Today Report. `reports.daily` applies only
`reports.daily_today` apply only to the existing Daily Today report. to the dated Daily Report.
Each report entry supports: Each report entry supports:
@@ -211,6 +211,7 @@ reports:
sections: sections:
- short_term - short_term
- spc_convective_discussion - spc_convective_discussion
- daily_planning
- hourly_forecast - hourly_forecast
today: today:
deterministic_modules: deterministic_modules:

View File

@@ -135,14 +135,16 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except `run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
from report definitions. Batch generation continues independent reports after a only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
failure, records each result, writes compact status lines to stderr, emits a batches. Batch output copy names come from report definitions. Batch generation
JSON summary to stdout, and returns an aggregate error when any report failed. continues independent reports after a failure, records each result, writes
When notification is enabled, each successfully generated report is notified compact status lines to stderr, emits a JSON summary to stdout, and returns an
independently. Notification failure marks that report failed, records aggregate error when any report failed. When notification is enabled, each
notification fields in the batch result, and does not stop later reports. successfully generated report is notified independently. Notification failure
`--out-dir` copies are never used as notification source files. marks that report failed, records notification fields in the batch result, and
does not stop later reports. `--out-dir` copies are never used as notification
source files.
## Inspection Workflow ## Inspection Workflow

View File

@@ -33,11 +33,16 @@ Outputs:
`weather_story` `weather_story`
- `module.Output` values for derived stanzas: - `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, `today_planning`, and `tomorrow_planning` `outdoor_windows`, `today_planning`, `tomorrow_planning`, and
`daily_planning`
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.
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
package and embedded Markdown template.
Tomorrow Report supports the Daily-style civil-day modules plus Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow `tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template. GeneratedText prompt package and embedded Markdown template.
@@ -50,13 +55,18 @@ package and embedded Markdown template.
morning readiness, commute/school/workday concerns, outdoor planning, and morning readiness, commute/school/workday concerns, outdoor planning, and
late-day change-watch fields. It is compatible with `report.Today` only. late-day change-watch fields. It is compatible with `report.Today` only.
`daily_planning` is a dated Daily deterministic planning stanza with morning
readiness, commute/school/workday concerns, and overnight change-watch fields.
It is compatible only with the `daily` report ID value. The default Daily
Report composition includes it.
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`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`. `area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, `derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
`today_planning`, or `tomorrow_planning`. `today_planning`, `tomorrow_planning`, or `daily_planning`.
Prompt-facing module values use local, human-readable date and time labels Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps where the LLM is expected to reason about report content. Canonical timestamps

View File

@@ -9,30 +9,45 @@ This document describes structured generated-text handling in
generated-text-template reports and builds curated render contexts for generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Today Report, Tomorrow Report, and Hourly assets. The implemented contracts are Daily render context, Today Report,
Report. Tomorrow Report, and Hourly Report.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- raw GeneratedText JSON for Today, Tomorrow Report, or Hourly Report - raw GeneratedText JSON for Daily, Today, 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 generated text - validated generated text
Outputs: Outputs:
- typed `Daily` generated text
- typed `Today` generated text - typed `Today` generated text
- typed `Tomorrow` generated text - typed `Tomorrow` generated text
- typed `Hourly` generated text - typed `Hourly` generated text
- normalized stable JSON for validated generated text - normalized stable JSON for validated generated text
- typed `DailyRenderContext` values for `internal/reporttemplate`
- typed `TodayRenderContext` values for `internal/reporttemplate` - typed `TodayRenderContext` values for `internal/reporttemplate`
- typed `TomorrowRenderContext` values for `internal/reporttemplate` - typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate` - typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use - generated-text catalog handlers for report definitions that use
`generated_text_template` `generated_text_template`
The Daily generated text JSON accepts the same public fields and validation
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
template ID `daily`, with prompt ID `weather.daily_generated_text`.
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
The hourly generated text JSON accepts: The hourly generated text JSON accepts:
```json ```json
@@ -96,8 +111,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 fields fail after trimming whitespace. - Empty required fields fail after trimming whitespace.
- Today and Tomorrow forecast discussion fails when no nonblank paragraphs - Daily, Today, and Tomorrow forecast discussion fails when no nonblank
remain. 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.
@@ -109,6 +124,7 @@ JSON when blank.
Inspect: Inspect:
- `internal/generatedtext/hourly_test.go` - `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/daily_test.go`
- `internal/generatedtext/today_test.go` - `internal/generatedtext/today_test.go`
- `internal/generatedtext/tomorrow_test.go` - `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go` - `internal/generatedtext/catalog_test.go`

View File

@@ -44,10 +44,33 @@ The registry recognizes these IDs:
- `outdoor_windows` - `outdoor_windows`
- `today_planning` - `today_planning`
- `tomorrow_planning` - `tomorrow_planning`
- `daily_planning`
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.
## Daily Composition
The default Daily 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. `daily_planning`
14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Today Composition ## Today Composition
The default Today Report module order is: The default Today Report module order is:
@@ -92,6 +115,22 @@ The default Tomorrow Report module order is:
The embedded Tomorrow template uses selected deterministic fields from these The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation. module outputs after GeneratedText validation.
## Daily Planning
`daily_planning` emits dated daily planning facts for the `daily` report ID.
Its output stanza is also named `daily_planning`. The module is supported only
by that report ID and depends on daily summaries for the selected local civil
day. The default Daily Report composition includes it.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `overnight_change_watch`
The type is `briefing.DailyPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Today Planning ## Today Planning
`today_planning` emits current-day planning facts for Today Report. Its output `today_planning` emits current-day planning facts for Today Report. Its output
@@ -99,13 +138,12 @@ stanza is also named `today_planning`. The module is supported only by Today
Report and depends on daily and daypart summaries for the current local civil Report and depends on daily and daypart summaries for the current local civil
day. day.
The output uses the same shape as Tomorrow planning: The output uses this shape:
- `morning_readiness` - `morning_readiness`
- `commute` - `commute_school_workday_concerns`
- `outdoor_planning` - `outdoor_planning`
- `things_to_watch` - `late_day_change_watch`
- `confidence`
The type is `briefing.TodayPlanningModule`; it is independent from The type is `briefing.TodayPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`. `briefing.TomorrowPlanningModule`.

View File

@@ -68,9 +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.
Today Report, Tomorrow Report, and Hourly Report module snapshots use the same Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
package schema and categories when converted into prompt input. The default use the same package schema and categories when converted into prompt input.
hourly module list places 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
@@ -78,10 +78,11 @@ 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 Today and Tomorrow module lists include civil-day summary stanzas, The default Daily, Today, and Tomorrow module lists include civil-day summary
planning stanzas, and `hourly_forecast` in the data package before structured stanzas, planning stanzas, and `hourly_forecast` in the data package before
GeneratedText is requested from Scriptorium. Today uses `today_planning`; structured GeneratedText is requested from Scriptorium. Daily uses
Tomorrow uses `tomorrow_planning`. `daily_planning`, Today uses `today_planning`, and Tomorrow uses
`tomorrow_planning`.
Current categories are: Current categories are:

View File

@@ -29,25 +29,25 @@ Each report definition declares:
Report-owned helpers map public command names and config keys to report IDs. Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `today`, `tomorrow`, `hourly`, The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`three-day`, `weekend`, and `storm`. Config keys also accept underscore and `three-day`, `weekend`, and `storm`. Config keys also accept selected
legacy descriptive aliases such as `daily_today`, `three_day_outlook`, underscore and descriptive aliases such as `three_day_outlook`,
`weekend_outlook`, and `storm_report`. `weekend_outlook`, and `storm_report`.
`daily` resolves to the existing Daily Today report ID `daily_today`. `daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
`today` resolves to the independent Today report ID `today`. `reports.today` independent Today report ID `today`. `reports.today` is not an alias for
is not an alias for `reports.daily` or `reports.daily_today`. `reports.daily`, and retired report keys are not supported.
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. Today Report, Their template and structured-text schema identifiers are empty. Daily Report,
Tomorrow Report, and Hourly Report declare `generated_text_template`; the app Today Report, Tomorrow Report, and Hourly Report declare
uses their template and schema identifiers to validate generated text and render `generated_text_template`; the app uses their template and schema identifiers
embedded Markdown templates. to validate generated text and render embedded 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 Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report | | Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report | | 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 |
@@ -59,8 +59,8 @@ All report definitions are eligible for generation.
## Valid Periods ## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil - Daily Report covers the selected local civil day and requires an explicit
day when no date override is supplied. date.
- Today Report covers the selected local civil day, or the current local civil - Today Report 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.
- Tomorrow Report covers the next local civil day from generation time. - Tomorrow Report covers the next local civil day from generation time.
@@ -91,14 +91,13 @@ report override keys.
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 short keys such as `today`, `tomorrow`, and `hourly`, module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
canonical report IDs such as `daily_today`, or descriptive names such as `hourly`, or descriptive names such as `three_day_outlook`.
`three_day_outlook`.
## Batch Membership ## Batch Membership
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
except on Sunday. Evening batches include Tomorrow Report. Daily Today and except on Sunday. Evening batches include Tomorrow Report. Daily Report and
Hourly Report are not part of a scheduled batch. Hourly Report are not part of a scheduled batch.
## State And App Usage ## State And App Usage

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 implemented template contracts are Today GeneratedText JSON schemas. The implemented template assets are Daily, Today,
Report, Tomorrow Report, and Hourly Report. Tomorrow, and Hourly.
The package embeds assets from: The package embeds assets from:
@@ -31,13 +31,13 @@ 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 IDs are `today`, `tomorrow`, and `hourly`. The The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
matching `*.generated_text.schema.json` files. `hourly`, backed by matching `*.generated_text.schema.json` files.
The Today generated-text prompt source is Generated-text prompt sources are maintained under
`internal/reporttemplate/prompts/today.generated_text.md`, selected by prompt `internal/reporttemplate/prompts/`, including Daily's
ID `weather.today_generated_text`. `daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
## Boundaries ## Boundaries
@@ -53,7 +53,7 @@ render-context builder, and embedded assets.
## Template Contracts ## Template Contracts
Today, Tomorrow, and Hourly rendering use typed render contexts with: Daily, Today, 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
@@ -62,10 +62,13 @@ Today, Tomorrow, and Hourly rendering use typed render contexts with:
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
Today and Tomorrow additionally expose forecast-date labels, ordered daypart Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph daypart forecast rows, daily/daypart summaries, planning facts, and a
forecast discussion generated-text slot. The ordered daypart slice is built in multi-paragraph forecast discussion generated-text slot. The ordered daypart
Go so templates do not range over maps. slice is built in Go so templates do not range over maps.
The Daily template asset uses the same Markdown structure as Tomorrow's
template and renders from `generatedtext.DailyRenderContext`.
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.
@@ -78,10 +81,11 @@ to write for each generated-text prompt. Hourly requires:
- `summary` - `summary`
- `forecast_discussion` - `forecast_discussion`
Today and Tomorrow require `summary` and a nonempty `forecast_discussion` Daily, Today, and Tomorrow require `summary` and a nonempty
array. All generated-text schemas allow optional `precipitation_timing` and `forecast_discussion` array. All generated-text schemas allow optional
`confidence`, and reject additional properties. Weather truth remains in module `precipitation_timing` and `confidence`, and reject additional properties.
outputs; GeneratedText is limited to prose slots consumed by the template. 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,7 @@ 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 compares with prior Daily Today snapshots for the same valid - Daily Report compares with prior Daily Report snapshots for the same valid
local date. local date.
- Today Report compares with prior Today Report snapshots for the same valid - Today Report compares with prior Today Report snapshots for the same valid
local date. local date.

View File

@@ -24,16 +24,15 @@ a JSON module snapshot, build a YAML prompt input data package, run
workspace. Markdown-path reports then run `scriptorium run` directly to the workspace. Markdown-path reports then run `scriptorium run` directly to the
managed Markdown report path. managed Markdown report path.
`generate today`, `generate tomorrow`, and `generate hourly` use the `generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
generated-text-template workflow. They run structured `scriptorium run` to raw use the generated-text-template workflow. They run structured `scriptorium run`
GeneratedText JSON, validate the structured text, save a render context, and to raw GeneratedText JSON, validate the structured text, save a render context,
render the managed Markdown report from embedded templates. `generate today` and render the managed Markdown report from embedded templates. `generate
covers the selected or current local civil day. `generate hourly` covers the daily` requires `--date YYYY-MM-DD` for the selected local civil day.
six-hour rolling period from generation time in the effective report timezone `generate today` covers the selected or current local civil day. `generate
and is not part of scheduled morning or evening batches. hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening
`generate daily` remains the existing Daily Today report and is independent of batches.
`generate today`.
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
@@ -69,6 +68,10 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.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
today/ today/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -196,7 +199,7 @@ period do not overwrite each other.
RunIDs are based on generation time plus report ID: RunIDs are based on generation time plus report ID:
```text ```text
20260529T100000.123456789Z_daily_today 20260529T100000.123456789Z_daily
20260529T100000.123456789Z_today 20260529T100000.123456789Z_today
``` ```
@@ -239,6 +242,8 @@ The default idempotency key appends RunID to the rendered bundle ID so each
report generation has a distinct retry identity. The default bundle path uses report generation has a distinct retry identity. The default bundle path uses
the valid-period start date, artifact group, and RunID. Distributor owns the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`. destination merge, retention, and derived snapshot behavior such as `latest`.
For Daily, the default report ID and artifact group values are both `daily`,
and the default output filename value is `daily.md`.
For Today, the default report ID and artifact group values are both `today`, For Today, the default report ID and artifact group values are both `today`,
and the batch output filename value is `today.md`. and the batch output filename value is `today.md`.
@@ -293,13 +298,13 @@ 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 compares with prior Daily Today snapshots for the same valid local Daily Report compares with prior Daily Report snapshots for the same valid
date. Today Report compares with prior Today Report snapshots for the same local date. Today Report compares with prior Today Report snapshots for the
valid local date. Tomorrow Report compares with prior Tomorrow Report snapshots same valid local date. Tomorrow Report compares with prior Tomorrow Report
for the same valid local date. 3-Day Outlook compares with prior compatible snapshots for the same valid local date. 3-Day Outlook compares with prior
3-Day snapshots for the same valid local date. Weekend Outlook compares with compatible 3-Day snapshots for the same valid local date. Weekend Outlook
prior compatible Weekend snapshots for the same weekend window. Hourly Report compares with prior compatible Weekend snapshots for the same weekend window.
and Storm Report leave Recent Changes empty. Hourly Report and Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed, When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty. `recentChanges.items` is empty.
@@ -312,7 +317,7 @@ A failed generation run may still leave useful artifacts:
preflight JSON and metadata are written for inspection. preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report - If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available. and metadata remain available.
- Generated-text failures for Today, Tomorrow, and Hourly reports preserve - Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
available intermediate artifacts, such as the structured run result, raw available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written. links those paths when it can be safely written.

View File

@@ -8,14 +8,15 @@ especially generated-text-template reports.
Templates are Go `text/template` files. The current implemented templates are: Templates are Go `text/template` files. The current implemented templates are:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl` - `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl` - `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl` - `internal/reporttemplate/templates/hourly.md.tmpl`
Templates are rendered from structured contexts such as `TodayRenderContext`, Templates are rendered from structured contexts such as `DailyRenderContext`,
`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection, `TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
derivation, module execution, generated text validation, and artifact paths are Weather data collection, derivation, module execution, generated text
handled before template rendering. validation, and artifact paths are handled before template rendering.
## Editing Rules ## Editing Rules
@@ -115,6 +116,37 @@ Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and `.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries. falls back to sorted keys for any unmatched entries.
## Daily Context
The Daily template receives the same five top-level values as Tomorrow, using
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`.
Daily 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.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected 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.
## Today Context ## Today Context
The Today template receives the same five top-level values as Tomorrow, using The Today template receives the same five top-level values as Tomorrow, using
@@ -328,6 +360,6 @@ go run ./cmd/weatherreporter --help
git diff --check git diff --check
``` ```
Template render tests currently exercise the hourly template through Template render tests exercise the Daily, Today, Tomorrow, and Hourly
`internal/generatedtext/render_context_test.go` and templates through `internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`. `internal/reporttemplate/reporttemplate_test.go`.

View File

@@ -187,8 +187,8 @@ Relevant docs: [Operations guide](operations.md),
## Generated Text Validation Fails ## Generated Text Validation Fails
Symptom: Today, Tomorrow, or Hourly generation fails with generated-text decode, Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
unknown-field, required-field, or multiple-JSON-values context. decode, unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report. GeneratedText contract for the selected report.
@@ -210,8 +210,8 @@ Relevant docs: [Operations guide](operations.md),
## Template Rendering Fails ## Template Rendering Fails
Symptom: Today, Tomorrow, or Hourly generation fails with report template parsing or Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
execution context after generated text validation succeeds. parsing or execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context. receives a value shape that does not match its typed render context.

View File

@@ -88,6 +88,7 @@ reports:
- spc_convective_discussion - spc_convective_discussion
- weather_story - weather_story
- outdoor_windows - outdoor_windows
- daily_planning
- hourly_forecast - hourly_forecast
today: today:
deterministic_modules: deterministic_modules:

View File

@@ -19,7 +19,7 @@ func TestRenderConstructsCommand(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err != nil { if err != nil {
@@ -30,7 +30,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"render", "render",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--format", "json", "--format", "json",
} }
@@ -56,7 +56,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err == nil { if err == nil {
@@ -84,7 +84,7 @@ func TestRunConstructsCommand(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
@@ -96,7 +96,7 @@ func TestRunConstructsCommand(t *testing.T) {
"run", "run",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md", "--out", "/tmp/daily.md",
} }
@@ -129,7 +129,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })

View File

@@ -1107,7 +1107,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.Today, report.Tomorrow: case report.Daily, report.Today, 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)

View File

@@ -105,6 +105,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
ExitCode: 0, ExitCode: 0,
OutputPath: "", OutputPath: "",
}, },
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
runBody: "# Daily Report\n\nRain this morning.\n", runBody: "# Daily Report\n\nRain this morning.\n",
} }
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
@@ -128,25 +133,28 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if renderer.renderCalls != 1 { if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls) t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
} }
if renderer.runCalls != 1 { if renderer.structuredRunCalls != 1 {
t.Fatalf("run calls = %d, want 1", renderer.runCalls) t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
} }
if renderer.renderRequest.PromptID != "weather.daily_report" { if renderer.runCalls != 0 {
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID) t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.renderRequest.PromptID != "weather.daily_generated_text" {
t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID)
} }
if renderer.renderRequest.DataPackagePath != result.DataPackagePath { if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath) t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
} }
if renderer.runRequest.DataPackagePath != result.DataPackagePath { if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath) t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
} }
if renderer.runRequest.OutputPath != result.ReportPath { if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) t.Fatalf("structured run OutputPath = %q, want raw generated text path %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
} }
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) { if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
t.Fatalf("store calls = %v, want prefix %s", store.calls, want) t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
} }
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath) assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath) snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil { if err != nil {
t.Fatalf("read module snapshot: %v", err) t.Fatalf("read module snapshot: %v", err)
@@ -252,7 +260,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read report output: %v", err) t.Fatalf("read report output: %v", err)
} }
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
} }
@@ -754,8 +762,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportDaily, Report: ReportThreeDay,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00")) }, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
@@ -764,7 +771,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"), Renderer: successfulRenderer("# 3-Day Outlook\n"),
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1125,7 +1132,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
if req.PipelineID != "weatherreporter.daily" { if req.PipelineID != "weatherreporter.daily" {
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID) t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
} }
if req.BundleID != "weatherreporter.home.daily_today" { if req.BundleID != "weatherreporter.home.daily" {
t.Fatalf("notification BundleID = %q, want default template", req.BundleID) t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
} }
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID { if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
@@ -1208,10 +1215,9 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
{ {
name: "Run", name: "Run",
renderer: &recordingRenderer{ renderer: &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"}, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"},
runErr: errors.New("run failed"), structuredRunErr: errors.New("run failed"),
runBody: "# Daily Report\n",
}, },
}, },
} }
@@ -1350,12 +1356,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
} }
renderer := &recordingRenderer{ renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ structuredRunResult: &scriptorium.StructuredRunResult{
Stderr: "validation failed", Stderr: "validation failed",
ExitCode: 2, ExitCode: 2,
}, },
runErr: errors.New("scriptorium run exited with code 2: validation failed"), structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
runBody: "# Daily Report\n",
} }
_, err = GenerateReport(context.Background(), ReportRequest{ _, err = GenerateReport(context.Background(), ReportRequest{
@@ -1366,8 +1371,8 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want run error") t.Fatal("GenerateReport() error = nil, want run error")
} }
if renderer.renderCalls != 1 || renderer.runCalls != 1 { if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls) t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
} }
store, err := state.NewFilesystemStore(cfg.Workspace) store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil { if err != nil {
@@ -1380,8 +1385,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
if _, err := os.Stat(paths.Metadata); err != nil { if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err) t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
} }
if _, err := os.Stat(paths.RenderedReport); err != nil { if _, err := os.Stat(paths.GeneratedTextRaw); err != nil {
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err) t.Fatalf("expected raw generated text from failed run %q: %v", paths.GeneratedTextRaw, err)
}
if _, err := os.Stat(paths.RenderedReport); !os.IsNotExist(err) {
t.Fatalf("rendered report exists after failed generated-text run: %v", err)
} }
} }
@@ -2053,6 +2061,22 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
} }
} }
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
_, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
}, mustParse("2026-05-29T08:00:00-05:00"))
if err == nil {
t.Fatal("ResolveGenerate() error = nil, want required date error")
}
if !strings.Contains(err.Error(), "requires an explicit date") {
t.Fatalf("ResolveGenerate() error = %q, want required date context", err.Error())
}
}
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) { func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(` if err := os.WriteFile(path, []byte(`
@@ -2552,6 +2576,10 @@ func validTodayGeneratedTextJSON() string {
return `{"summary":"Today 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."}` return `{"summary":"Today 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 validDailyGeneratedTextJSON() string {
return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
}
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()
@@ -2835,9 +2863,10 @@ func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metada
func successfulRenderer(body string) *recordingRenderer { func successfulRenderer(body string) *recordingRenderer {
return &recordingRenderer{ return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: body, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
runBody: body,
} }
} }
@@ -2913,6 +2942,9 @@ func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.Str
if req.PromptID == "weather.tomorrow_generated_text" { if req.PromptID == "weather.tomorrow_generated_text" {
body = validTomorrowGeneratedTextJSON() body = validTomorrowGeneratedTextJSON()
} }
if req.PromptID == "weather.daily_generated_text" {
body = validDailyGeneratedTextJSON()
}
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err return nil, err
} }
@@ -2942,8 +2974,12 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++ r.structuredRunCalls++
r.structuredRunRequest = req r.structuredRunRequest = req
if r.structuredRunBody != "" { body := r.structuredRunBody
if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil { if body == "" {
body = validGeneratedTextJSONForPrompt(req.PromptID)
}
if body != "" {
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err return nil, err
} }
} }
@@ -2952,3 +2988,18 @@ func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.Str
} }
return r.structuredRunResult, r.structuredRunErr return r.structuredRunResult, r.structuredRunErr
} }
func validGeneratedTextJSONForPrompt(promptID string) string {
switch promptID {
case "weather.daily_generated_text":
return validDailyGeneratedTextJSON()
case "weather.today_generated_text":
return validTodayGeneratedTextJSON()
case "weather.tomorrow_generated_text":
return validTomorrowGeneratedTextJSON()
case "weather.hourly_generated_text":
return validHourlyGeneratedTextJSON()
default:
return ""
}
}

View File

@@ -202,7 +202,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[MetadataModule](t, output) value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
t.Fatalf("metadata = %#v, want report identity", value) t.Fatalf("metadata = %#v, want report identity", value)
} }
if value.Location == nil || value.Location.Name != "Brentwood" { if value.Location == nil || value.Location.Name != "Brentwood" {
@@ -395,7 +395,7 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
func testModuleContext() ModuleContext { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday) definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{ resolved := report.Resolved{
Definition: definition, Definition: definition,
GeneratedAt: generatedAt, GeneratedAt: generatedAt,

View File

@@ -0,0 +1,24 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type DailyPlanningModule struct {
MorningReadiness []string `json:"morning_readiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
}
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
}
planning := buildMorningCommuteOvernightPlanning(summary)
value := DailyPlanningModule{}
if planning != nil {
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
}
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
}

View File

@@ -16,7 +16,7 @@ import (
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil { if err != nil {
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil { if err != nil {
@@ -154,7 +154,7 @@ func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil { if err != nil {
@@ -337,6 +337,77 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
} }
} }
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
t.Fatalf("output = %#v, want daily planning stanza", output)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
}
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
}
}
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
ctx.Derived.DailySummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) != 0 ||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
len(planning.OvernightChangeWatch) != 0 {
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
}
}
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) { func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := todayModuleContext() ctx := todayModuleContext()
@@ -382,7 +453,7 @@ func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) { func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) { t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id) ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
@@ -413,7 +484,7 @@ func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
func TestDerivedModulesHandleMissingData(t *testing.T) { func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries = nil ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil ctx.Derived.DaypartSummaries = nil
@@ -505,7 +576,7 @@ func derivedModuleContext(id report.ID) ModuleContext {
} }
func todayModuleContext() ModuleContext { func todayModuleContext() ModuleContext {
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Resolved.Definition = report.Definition{ ctx.Resolved.Definition = report.Definition{
ID: report.Today, ID: report.Today,
Name: "Today Report", Name: "Today Report",
@@ -514,6 +585,16 @@ func todayModuleContext() ModuleContext {
return ctx return ctx
} }
func dailyModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Tomorrow)
ctx.Resolved.Definition = report.Definition{
ID: report.Daily,
Name: "Daily Report",
PromptID: "weather.daily_generated_text",
}
return ctx
}
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
hour := derivedHour(start, text, precip, temperature, apparent, gust) hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{ return forecast.SummarizeDaypart(name, timeutil.Period{

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.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.Daily, report.Today, 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.Today, report.Tomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, 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.Today, report.Tomorrow, report.Hourly}, SupportedReports: []report.ID{report.Daily, report.Today, 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.Today, report.Tomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule, Builder: buildDerivedDailySummaryModule,
}, },
@@ -392,5 +392,14 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule, Builder: buildTomorrowPlanningModule,
}, },
{
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,
},
} }
} }

View File

@@ -77,7 +77,7 @@ func TestHourlyDefaultModuleOptions(t *testing.T) {
func TestModuleRegistryRejectsUnknownModule(t *testing.T) { func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) { if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
t.Fatalf("error = %v, want unknown module", err) t.Fatalf("error = %v, want unknown module", err)
} }
@@ -85,7 +85,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) { func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.Metadata}, {ID: module.Metadata},
}) })
@@ -106,8 +106,8 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) { if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
t.Fatalf("error = %v, want incompatible report", err) t.Fatalf("error = %v, want incompatible report", err)
} }
} }
@@ -117,7 +117,7 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil { if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
t.Fatalf("ValidateComposition(today) error = %v", err) t.Fatalf("ValidateComposition(today) error = %v", err)
} }
for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) { t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}}) err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) { if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
@@ -127,6 +127,21 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
} }
} }
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err)
}
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) { func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Today, []module.ConfigItem{ err := registry.ValidateComposition(report.Today, []module.ConfigItem{
@@ -158,6 +173,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
module.OutdoorWindows, module.OutdoorWindows,
module.TodayPlanning, module.TodayPlanning,
module.TomorrowPlanning, module.TomorrowPlanning,
module.DailyPlanning,
} { } {
t.Run(string(id), func(t *testing.T) { t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}}) err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
@@ -188,7 +204,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}}, {ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
}) })
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) { if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
@@ -198,7 +214,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}}, {ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}}, {ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
}) })

View File

@@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday, report.Today: case report.Daily, report.Today:
return "today" return "today"
case report.Tomorrow: case report.Tomorrow:
return "tomorrow" return "tomorrow"

View File

@@ -28,6 +28,12 @@ type TomorrowPlanning struct {
OvernightChangeWatch []string OvernightChangeWatch []string
} }
type morningCommuteOvernightPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type TodayPlanning struct { type TodayPlanning struct {
MorningReadiness []string MorningReadiness []string
CommuteSchoolWorkdayConcerns []string CommuteSchoolWorkdayConcerns []string
@@ -99,7 +105,16 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
} }
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
planning := &TomorrowPlanning{} base := buildMorningCommuteOvernightPlanning(summary)
return &TomorrowPlanning{
MorningReadiness: append([]string(nil), base.MorningReadiness...),
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
}
}
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
planning := &morningCommuteOvernightPlanning{}
morning := daypartNamed(summary.Dayparts, "morning") morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil { if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)

View File

@@ -17,7 +17,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
@@ -217,7 +217,15 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
} }
switch reportKind { switch reportKind {
case app.ReportDaily, app.ReportToday: case app.ReportDaily:
if opts.Date == "" {
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, err
}
case app.ReportToday:
if opts.Date == "" { if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location) req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else { } else {

View File

@@ -23,7 +23,7 @@ func TestRunHelpLongFlag(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(output.stdout, "generate daily") { if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") {
t.Fatalf("help output missing generate command:\n%s", output.stdout) t.Fatalf("help output missing generate command:\n%s", output.stdout)
} }
if !strings.Contains(output.stdout, "generate today") { if !strings.Contains(output.stdout, "generate today") {
@@ -251,7 +251,7 @@ func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
Failed: 1, Failed: 1,
Reports: []app.BatchReportResult{ Reports: []app.BatchReportResult{
{ {
ReportID: "daily_today", ReportID: "daily",
Status: "succeeded", Status: "succeeded",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
NotificationStatus: "accepted", NotificationStatus: "accepted",
@@ -299,11 +299,11 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
Failed: 1, Failed: 1,
Reports: []app.BatchReportResult{ Reports: []app.BatchReportResult{
{ {
ReportID: "daily_today", ReportID: "daily",
Status: "failed", Status: "failed",
Error: "notify report daily_today: upload failed: [redacted]", Error: "notify report daily: upload failed: [redacted]",
NotificationStatus: "failed", NotificationStatus: "failed",
NotificationError: "notify report daily_today: upload failed: [redacted]", NotificationError: "notify report daily: upload failed: [redacted]",
}, },
}, },
} }
@@ -526,7 +526,7 @@ func TestRunGenerateDailyWritesMarkdownReport(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), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
@@ -540,7 +540,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
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), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") { if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily") {
t.Fatalf("data package output missing expected content:\n%s", string(data)) t.Fatalf("data package output missing expected content:\n%s", string(data))
} }
if !strings.Contains(string(data), "location:") || if !strings.Contains(string(data), "location:") ||
@@ -571,6 +571,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if len(reportMatches) != 1 { if len(reportMatches) != 1 {
t.Fatalf("managed reports = %#v, want one", reportMatches) t.Fatalf("managed reports = %#v, want one", reportMatches)
} }
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json")
metadataPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
assertFileContains(t, metadataPath, `"generatedTextSchemaId": "daily"`)
} }
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
@@ -975,14 +983,44 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
} }
} }
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { func TestResolveGenerateDailyRequiresDate(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily"}) req, err := runner.resolveGenerate([]string{"daily"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want required date error")
}
if !strings.Contains(err.Error(), "generate daily requires --date YYYY-MM-DD") {
t.Fatalf("resolveGenerate() error = %q, want required date context", err.Error())
}
if !req.Date.IsZero() {
t.Fatalf("Date = %s, want unset on error", req.Date)
}
}
func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want date parse error")
}
if !strings.Contains(err.Error(), `parse date "bad-date" as YYYY-MM-DD`) {
t.Fatalf("resolveGenerate() error = %q, want date parse context", err.Error())
}
}
func TestResolveGenerateDailyParsesDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"})
if err != nil { if err != nil {
t.Fatalf("resolveGenerate() error = %v", err) t.Fatalf("resolveGenerate() error = %v", err)
} }
if req.Report != app.ReportDaily {
t.Fatalf("Report = %q, want daily", req.Report)
}
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
t.Fatalf("Date = %s, want 2026-05-29", got) t.Fatalf("Date = %s, want 2026-05-29", got)
} }
@@ -1024,7 +1062,7 @@ func TestResolveGenerateTodayDate(t *testing.T) {
func TestResolveGenerateAppliesSharedFlags(t *testing.T) { func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
if err != nil { if err != nil {
t.Fatalf("resolveGenerate() error = %v", err) t.Fatalf("resolveGenerate() error = %v", err)
} }
@@ -1299,6 +1337,21 @@ JSON
], ],
"precipitation_timing": "The best rain chance is during the morning." "precipitation_timing": "The best rain chance is during the morning."
} }
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.daily_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": [
"A front will keep rain chances in the forecast.",
"Temperatures stay seasonable by afternoon."
],
"precipitation_timing": "Rain is most likely during the afternoon.",
"confidence": "Medium"
}
JSON JSON
printf 'wrote generated text\n' >&2 printf 'wrote generated text\n' >&2
exit 0 exit 0
@@ -1345,6 +1398,21 @@ 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.daily_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": [
"A front will keep rain chances in the forecast.",
"Temperatures stay seasonable by afternoon."
],
"precipitation_timing": "Rain is most likely during the afternoon.",
"confidence": "Medium"
}
JSON JSON
printf 'wrote generated text\n' >&2 printf 'wrote generated text\n' >&2
exit 0 exit 0

View File

@@ -163,7 +163,7 @@ reports:
if err != nil { if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err) t.Fatalf("ReportModuleOverrides() error = %v", err)
} }
items := overrides[report.DailyToday] items := overrides[report.Daily]
if len(items) != 6 { if len(items) != 6 {
t.Fatalf("daily override length = %d, want 6", len(items)) t.Fatalf("daily override length = %d, want 6", len(items))
} }
@@ -220,8 +220,8 @@ reports:
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id) t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
} }
} }
if _, ok := overrides[report.DailyToday]; ok { if _, ok := overrides[report.Daily]; ok {
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.DailyToday]) t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily])
} }
} }
@@ -314,18 +314,19 @@ func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
} }
func TestValidateReportModuleAliasesDirectly(t *testing.T) { func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct { tests := []struct {
name string name string
reports map[string]ReportConfig reports map[string]ReportConfig
wantErr string wantErr string
}{ }{
{ {
name: "DuplicateAlias", name: "RetiredDailyReportID",
reports: map[string]ReportConfig{ reports: map[string]ReportConfig{
"daily": {}, "daily": {},
"daily_today": {}, retiredDailyKey: {},
}, },
wantErr: "duplicates report override", wantErr: "reports." + retiredDailyKey,
}, },
{ {
name: "TodayAndDailyAreDistinct", name: "TodayAndDailyAreDistinct",
@@ -381,6 +382,7 @@ func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
} }
func TestReportModuleOverrideValidation(t *testing.T) { func TestReportModuleOverrideValidation(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct { tests := []struct {
name string name string
yaml string yaml string
@@ -425,7 +427,7 @@ reports:
deterministic_modules: deterministic_modules:
- tomorrow_planning - tomorrow_planning
`, `,
wantErr: `not compatible with report "daily_today"`, wantErr: `not compatible with report "daily"`,
}, },
{ {
name: "RemovedPlaceholderModule", name: "RemovedPlaceholderModule",
@@ -461,17 +463,17 @@ reports:
wantErr: "options are invalid", wantErr: "options are invalid",
}, },
{ {
name: "DuplicateReportAlias", name: "RetiredDailyReportID",
yaml: ` yaml: `
reports: reports:
daily: daily:
deterministic_modules: deterministic_modules:
- metadata - metadata
daily_today: ` + retiredDailyKey + `:
deterministic_modules: deterministic_modules:
- current_conditions - current_conditions
`, `,
wantErr: "duplicates report override", wantErr: "reports." + retiredDailyKey,
}, },
{ {
name: "HourlyIncompatibleDailyModule", name: "HourlyIncompatibleDailyModule",
@@ -508,6 +510,10 @@ reports:
} }
} }
func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_")
}
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) { func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
for _, key := range []string{ for _, key := range []string{
strings.Join([]string{"near", "term"}, "_"), strings.Join([]string{"near", "term"}, "_"),

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.Today, report.Tomorrow: case report.Daily, report.Today, 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

@@ -56,7 +56,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
Timezone: location.String(), Timezone: location.String(),
@@ -252,8 +252,8 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
wantDiscussion []string wantDiscussion []string
}{ }{
{ {
name: "daily today", name: "daily",
resolved: resolveForTest(t, report.DailyToday, now, location), resolved: resolveForTest(t, report.Daily, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}, wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"}, wantDiscussion: []string{"day1 early", "day1 late"},
}, },
@@ -322,7 +322,7 @@ func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) { func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location) bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = nil bundle.SPCConvectiveOutlooks = nil
@@ -465,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved { func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
t.Helper() t.Helper()
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location}) req := report.ResolveRequest{Now: now, Location: location}
if id == report.Daily {
req.Date = now
}
resolved, err := report.Resolve(id, req)
if err != nil { if err != nil {
t.Fatalf("resolve %s: %v", id, err) t.Fatalf("resolve %s: %v", id, err)
} }

View File

@@ -12,10 +12,12 @@ import (
const ( const (
schemaIDHourly = "hourly" schemaIDHourly = "hourly"
schemaIDDaily = "daily"
schemaIDToday = "today" schemaIDToday = "today"
schemaIDTomorrow = "tomorrow" schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly" templateIDHourly = "hourly"
templateIDDaily = "daily"
templateIDToday = "today" templateIDToday = "today"
templateIDTomorrow = "tomorrow" templateIDTomorrow = "tomorrow"
) )
@@ -46,6 +48,12 @@ var catalog = []catalogEntry{
validate: validateHourly, validate: validateHourly,
renderContextBuilder: buildHourlyContext, renderContextBuilder: buildHourlyContext,
}, },
{
schemaID: schemaIDDaily,
templateID: templateIDDaily,
validate: validateDaily,
renderContextBuilder: buildDailyContext,
},
{ {
schemaID: schemaIDToday, schemaID: schemaIDToday,
templateID: templateIDToday, templateID: templateIDToday,
@@ -142,6 +150,10 @@ func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data) return ValidateHourly(data)
} }
func validateDaily(data []byte) (any, []byte, error) {
return ValidateDaily(data)
}
func validateToday(data []byte) (any, []byte, error) { func validateToday(data []byte) (any, []byte, error) {
return ValidateToday(data) return ValidateToday(data)
} }
@@ -158,6 +170,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived) return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
} }
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
daily, ok := generated.(Daily)
if !ok {
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
}
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
}
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
today, ok := generated.(Today) today, ok := generated.(Today)
if !ok { if !ok {

View File

@@ -119,6 +119,32 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
} }
} }
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
definition := report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Daily template source", template)
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly)) hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil { if err != nil {
@@ -178,6 +204,29 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) { if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized) t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
} }
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
daily, normalized, err := dailyHandler.Validate([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(daily) error = %v", err)
}
if _, ok := daily.(Daily); !ok {
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
}
} }
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
@@ -230,4 +279,46 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if !strings.Contains(err.Error(), `requires today generated text`) { if !strings.Contains(err.Error(), `requires today generated text`) {
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err) t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
} }
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires daily generated text`) {
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
}
}
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
handler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
Summary: "Showers are possible during the selected day.",
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
})
if err != nil {
t.Fatalf("BuildRenderContext(daily) error = %v", err)
}
if _, ok := ctx.(DailyRenderContext); !ok {
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
}
} }

View File

@@ -0,0 +1,37 @@
package generatedtext
import (
"fmt"
"strings"
)
type Daily struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateDaily(data []byte) (Daily, []byte, error) {
value, err := decodeGeneratedText[Daily](data, "daily")
if err != nil {
return Daily{}, nil, err
}
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 Daily{}, nil, fmt.Errorf("daily generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "daily")
if err != nil {
return Daily{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateDailyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateDaily([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [
" A front will keep rain chances in the forecast. ",
"",
" Temperatures stay seasonable by afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
if value.Summary != "Showers are possible during the selected day." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances 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 during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateDaily([]byte(`{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": ["A front will keep rain chances in the forecast."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode daily generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are possible during the selected day."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateDaily([]byte(test.in))
if err == nil {
t.Fatal("ValidateDaily() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -52,6 +52,14 @@ type TomorrowRenderContext struct {
Derived facts.DerivedFacts Derived facts.DerivedFacts
} }
type DailyRenderContext struct {
Report DailyReportContext
GeneratedText Daily
Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayRenderContext struct { type TodayRenderContext struct {
Report TodayReportContext Report TodayReportContext
GeneratedText Today GeneratedText Today
@@ -124,6 +132,39 @@ type TomorrowDaypartContext struct {
Summary briefing.DerivedDaypartSummaryModule Summary briefing.DerivedDaypartSummaryModule
} }
type DailyReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type DailyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []DailyDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
OutdoorWindows *briefing.OutdoorWindowsModule
DailyPlanning *briefing.DailyPlanningModule
}
type DailyDaypartContext 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 {
@@ -156,6 +197,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}, nil }, nil
} }
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required")
}
modules, err := dailyTemplateModules(snapshot, derived)
if err != nil {
return DailyRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return DailyRenderContext{
Report: DailyReportContext{
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 BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil { if err != nil {
@@ -225,6 +301,78 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil }, nil
} }
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return DailyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return DailyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return DailyTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return DailyTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return DailyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return DailyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return DailyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return DailyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return DailyTemplateModules{}, err
}
outdoor, err := lookup.outdoorWindows()
if err != nil {
return DailyTemplateModules{}, err
}
planning, err := lookup.dailyPlanning()
if err != nil {
return DailyTemplateModules{}, err
}
return DailyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
OutdoorWindows: outdoor,
DailyPlanning: planning,
}, nil
}
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata() metadata, err := lookup.metadata()
@@ -470,6 +618,14 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory) return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
} }
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
}
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
}
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) { func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning) return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
} }
@@ -500,6 +656,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod
return out return out
} }
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]DailyDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext { func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered) orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TomorrowDaypartContext, 0, len(orderedRows)) out := make([]TomorrowDaypartContext, 0, len(orderedRows))

View File

@@ -260,6 +260,132 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
} }
} }
func TestBuildDailyRenderContext(t *testing.T) {
metadata := testDailyMetadata()
snapshot := testDailySnapshot(t)
generated := Daily{
Summary: "The selected day 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 := testDailyDerived()
ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildDailyRenderContext() 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 != "Saturday, June 13, 2026 at 9:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" {
t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone)
}
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Daily {
t.Fatalf("Modules.Metadata = %#v, want daily 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) != 3 {
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
}
if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" {
t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", 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.OutdoorWindows == nil || ctx.Modules.DailyPlanning == nil {
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
}
if ctx.Modules.OutdoorWindows.Best == nil || ctx.Modules.OutdoorWindows.Best.Daypart != "morning" {
t.Fatalf("Modules.OutdoorWindows = %#v, want morning best window", ctx.Modules.OutdoorWindows)
}
if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." {
t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning)
}
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("daily", 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",
"**Updated:** Saturday, June 13, 2026 at 9:14 AM",
"The selected day starts quiet, then showers become more likely later in the day.",
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"- **Evening:** Mostly cloudy, with temperatures in the upper 60s.",
"## 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:**",
"- **Evening:**",
"## Precipitation Timing",
"## Forecast Discussion",
})
}
func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{
Summary: "Dry weather is expected for the selected day.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err)
}
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
}
rendered, err := reporttemplate.Render("daily", 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 TestBuildTomorrowRenderContext(t *testing.T) { func TestBuildTomorrowRenderContext(t *testing.T) {
metadata := testTomorrowMetadata() metadata := testTomorrowMetadata()
snapshot := testTomorrowSnapshot(t) snapshot := testTomorrowSnapshot(t)
@@ -414,6 +540,26 @@ func testTomorrowMetadata() briefing.Metadata {
} }
} }
func testDailyMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-daily",
ReportID: report.Daily,
PromptID: "weather.daily_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 testTodayMetadata() briefing.Metadata { func testTodayMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC) generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.Metadata{
@@ -446,6 +592,10 @@ func testTomorrowDerived() facts.DerivedFacts {
return testCivilDayDerived() return testCivilDayDerived()
} }
func testDailyDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
func testTodayDerived() facts.DerivedFacts { func testTodayDerived() facts.DerivedFacts {
return testCivilDayDerived() return testCivilDayDerived()
} }
@@ -605,6 +755,154 @@ func testTodaySnapshot(t *testing.T) module.Snapshot {
return snapshot return snapshot
} }
func testDailySnapshot(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-daily",
ReportID: report.Daily,
PromptID: "weather.daily_generated_text",
GeneratedAt: testDailyMetadata().GeneratedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testDailyMetadata().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",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "mid 70s",
DominantConditionDisplay: "Showers",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
"evening": {
DisplayName: "Evening",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "upper 60s",
DominantConditionDisplay: "Mostly cloudy",
DominantConditionLower: "mostly cloudy",
},
"morning": {
DisplayName: "Morning",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "low 60s",
DominantConditionDisplay: "Partly cloudy",
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.OutdoorWindows,
StanzaName: string(module.OutdoorWindows),
Value: briefing.OutdoorWindowsModule{
Best: &briefing.OutdoorWindowModule{
Daypart: "morning",
Reasons: []string{"quiet weather"},
},
Worst: &briefing.OutdoorWindowModule{
Daypart: "afternoon",
Reasons: []string{"high precipitation chance"},
},
},
},
{
ID: module.DailyPlanning,
StanzaName: string(module.DailyPlanning),
Value: briefing.DailyPlanningModule{
MorningReadiness: []string{"Take sunglasses early."},
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
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{

View File

@@ -26,6 +26,7 @@ const (
OutdoorWindows ID = "outdoor_windows" OutdoorWindows ID = "outdoor_windows"
TodayPlanning ID = "today_planning" TodayPlanning ID = "today_planning"
TomorrowPlanning ID = "tomorrow_planning" TomorrowPlanning ID = "tomorrow_planning"
DailyPlanning ID = "daily_planning"
) )
type ConfigItem struct { type ConfigItem struct {
@@ -151,3 +152,4 @@ type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{} type OutdoorWindowsOptions struct{}
type TodayPlanningOptions struct{} type TodayPlanningOptions struct{}
type TomorrowPlanningOptions struct{} type TomorrowPlanningOptions struct{}
type DailyPlanningOptions struct{}

View File

@@ -100,3 +100,11 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
_ = SPCConvectiveOutlooksOptions{} _ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{} _ = SPCConvectiveDiscussionOptions{}
} }
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
if DailyPlanning != ID("daily_planning") {
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
}
_ = DailyPlanningOptions{}
}

View File

@@ -40,6 +40,7 @@ var briefingStanzaCategories = map[string]string{
string(module.DerivedDaypartSummaries): categoryDerivedSummaries, string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries, string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries, string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.DailyPlanning): categoryDerivedSummaries,
string(module.TodayPlanning): categoryDerivedSummaries, string(module.TodayPlanning): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts, string(module.NarrativeForecast): categoryNarrativeProducts,

View File

@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.SchemaVersion != SchemaVersion { if pkg.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
} }
if pkg.RunID != "20260529T100000Z_daily_today" { if pkg.RunID != "20260529T100000Z_daily" {
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID) t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
} }
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
} }
if pkg.Report.CurrentLocalDate != "2026-05-29" { if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
@@ -252,10 +252,10 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(` data := []byte(`
schema_version: weatherreporter.data_package.v2 schema_version: weatherreporter.data_package.v2
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
report: report:
id: daily_today id: daily
prompt_id: weather.daily_report prompt_id: weather.daily_generated_text
generated_at: 2026-05-29T10:00:00Z generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago timezone: America/Chicago
current_local_date: "2026-05-29" current_local_date: "2026-05-29"
@@ -264,7 +264,7 @@ report:
end: 2026-05-30T05:00:00Z end: 2026-05-30T05:00:00Z
briefing: briefing:
metadata: metadata:
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
raw_data: raw_data:
alert_digest: alert_digest:
checked: true checked: true
@@ -283,10 +283,10 @@ func validBuildRequest(t *testing.T) BuildRequest {
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return BuildRequest{ return BuildRequest{
Metadata: Metadata{ Metadata: Metadata{
RunID: "20260529T100000Z_daily_today", RunID: "20260529T100000Z_daily",
ReportID: report.DailyToday, ReportID: report.Daily,
Variant: "today", Variant: "today",
PromptID: "weather.daily_report", PromptID: "weather.daily_generated_text",
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
@@ -295,7 +295,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
}, },
}, },
Modules: snapshotWithOutputs(t, Modules: snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}}, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}}, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},

View File

@@ -1,24 +1,27 @@
package report package report
import ( import (
"fmt"
"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"
) )
func dailyTodayDefinition() Definition { func dailyDefinition() Definition {
return Definition{ return Definition{
ID: DailyToday, ID: Daily,
Name: "Daily Report", Name: "Daily Report",
PromptID: "weather.daily_report", PromptID: "weather.daily_generated_text",
GenerationMode: GenerationModeScriptoriumMarkdown, GenerationMode: GenerationModeGeneratedTextTemplate,
ComparisonStrategy: CompareSameValidDate, TemplateID: "daily",
ArtifactGroup: "daily", GeneratedTextSchemaID: "daily",
BatchOutputName: "daily.md", ComparisonStrategy: CompareSameValidDate,
Generated: true, ArtifactGroup: "daily",
CompatiblePriorIDs: []ID{DailyToday}, BatchOutputName: "daily.md",
Modules: dailyTodayModules(), Generated: true,
Morning: true, CompatiblePriorIDs: []ID{Daily},
resolve: resolveDailyToday, Modules: dailyModules(),
resolve: resolveDaily,
} }
} }
@@ -41,7 +44,7 @@ func tomorrowDefinition() Definition {
} }
} }
func dailyTodayModules() []module.ConfigItem { func dailyModules() []module.ConfigItem {
return moduleItems( return moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -55,6 +58,7 @@ func dailyTodayModules() []module.ConfigItem {
module.SPCConvectiveDiscussion, module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
) )
} }
@@ -78,11 +82,11 @@ func tomorrowModules() []module.ConfigItem {
) )
} }
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) { func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() { if req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
} }
return timeutil.CivilDay(req.Now, req.Location), nil return timeutil.CivilDay(req.Date, req.Location), nil
} }
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) { func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -12,13 +12,13 @@ import (
type ID string type ID string
const ( const (
DailyToday ID = "daily_today" Daily ID = "daily"
Today ID = "today" Today ID = "today"
Tomorrow ID = "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

@@ -21,7 +21,7 @@ const (
func IDForCommandName(name string) (ID, error) { func IDForCommandName(name string) (ID, error) {
switch name { switch name {
case CommandNameDaily: case CommandNameDaily:
return DailyToday, nil return Daily, nil
case CommandNameToday: case CommandNameToday:
return Today, nil return Today, nil
case CommandNameTomorrow: case CommandNameTomorrow:
@@ -54,8 +54,8 @@ func CommandNames() []string {
func IDForConfigKey(key string) (ID, error) { func IDForConfigKey(key string) (ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_") normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized { switch normalized {
case "daily", "daily_today": case "daily":
return DailyToday, nil return Daily, nil
case "today": case "today":
return Today, nil return Today, nil
case "tomorrow": case "tomorrow":

View File

@@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00") now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
if err != nil { if err == nil {
t.Fatalf("Resolve() error = %v", err) t.Fatal("Resolve() error = nil, want explicit date requirement")
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00") if !strings.Contains(err.Error(), "requires an explicit date") {
t.Fatalf("Resolve() error = %v, want explicit date requirement", err)
}
_ = resolved
} }
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
@@ -26,11 +29,23 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
now := mustParse("2026-05-29T17:45:00-05:00") now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00") date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date}) resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "daily" {
t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "daily" {
t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID)
}
} }
func TestTodayValidPeriod(t *testing.T) { func TestTodayValidPeriod(t *testing.T) {
@@ -256,7 +271,7 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
} }
} }
func TestBatchesDoNotIncludeHourly(t *testing.T) { func TestBatchesDoNotIncludeManualReports(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location} req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
@@ -269,8 +284,8 @@ func TestBatchesDoNotIncludeHourly(t *testing.T) {
t.Fatalf("BatchReports(evening) error = %v", err) t.Fatalf("BatchReports(evening) error = %v", err)
} }
for _, resolved := range append(morning, evening...) { for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == Hourly { if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID) t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
} }
} }
} }
@@ -280,7 +295,7 @@ func TestIDForCommandName(t *testing.T) {
name string name string
want ID want ID
}{ }{
{name: "daily", want: DailyToday}, {name: "daily", want: Daily},
{name: "today", want: Today}, {name: "today", want: Today},
{name: "tomorrow", want: Tomorrow}, {name: "tomorrow", want: Tomorrow},
{name: "hourly", want: Hourly}, {name: "hourly", want: Hourly},
@@ -323,8 +338,7 @@ func TestIDForConfigKey(t *testing.T) {
key string key string
want ID want ID
}{ }{
{key: "daily", want: DailyToday}, {key: "daily", want: Daily},
{key: "daily_today", want: DailyToday},
{key: "today", want: Today}, {key: "today", want: Today},
{key: "tomorrow", want: Tomorrow}, {key: "tomorrow", want: Tomorrow},
{key: "hourly", want: Hourly}, {key: "hourly", want: Hourly},
@@ -351,6 +365,10 @@ func TestIDForConfigKey(t *testing.T) {
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) { if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err) t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
} }
retiredDailyKey := strings.Join([]string{"daily", "today"}, "_")
if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) {
t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err)
}
dailyID, err := IDForConfigKey("daily") dailyID, err := IDForConfigKey("daily")
if err != nil { if err != nil {
t.Fatalf("IDForConfigKey(daily) error = %v", err) t.Fatalf("IDForConfigKey(daily) error = %v", err)
@@ -418,7 +436,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", "today", "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)
} }
@@ -443,7 +461,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
if !definition.Generated { if !definition.Generated {
continue continue
} }
if definition.ID == Hourly || definition.ID == Today || definition.ID == Tomorrow { if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow {
wantTemplate := string(definition.ID) 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)
@@ -478,11 +496,11 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
comparisonStrategy ComparisonStrategy comparisonStrategy ComparisonStrategy
}{ }{
{ {
id: DailyToday, id: Daily,
artifactGroup: "daily", artifactGroup: "daily",
batchOutputName: "daily.md", batchOutputName: "daily.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday}, compatiblePriorIDs: []ID{Daily},
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{ {
@@ -572,7 +590,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want []module.ID want []module.ID
}{ }{
{ {
id: DailyToday, id: Daily,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -586,6 +604,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.SPCConvectiveDiscussion, module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
}, },
}, },
@@ -703,7 +722,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
base := DefaultRegistry() base := DefaultRegistry()
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{ overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
DailyToday: { Daily: {
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.AlertDigest}, {ID: module.AlertDigest},
}, },
@@ -712,7 +731,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
t.Fatalf("WithModuleOverrides() error = %v", err) t.Fatalf("WithModuleOverrides() error = %v", err)
} }
definition, err := overridden.Lookup(DailyToday) definition, err := overridden.Lookup(Daily)
if err != nil { if err != nil {
t.Fatalf("Lookup(overridden) error = %v", err) t.Fatalf("Lookup(overridden) error = %v", err)
} }
@@ -720,7 +739,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs()) t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
} }
defaultDefinition, err := base.Lookup(DailyToday) defaultDefinition, err := base.Lookup(Daily)
if err != nil { if err != nil {
t.Fatalf("Lookup(default) error = %v", err) t.Fatalf("Lookup(default) error = %v", err)
} }
@@ -743,18 +762,22 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(Daily, ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
Date: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
metadata := resolved.Metadata() metadata := resolved.Metadata()
if metadata.ReportID != DailyToday { if metadata.ReportID != Daily {
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID) t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
} }
if metadata.PromptID != "weather.daily_report" { if metadata.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "daily_today") { if !strings.Contains(metadata.RunID, "daily") {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want report id", metadata.RunID)
} }
} }

View File

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

View File

@@ -12,12 +12,14 @@ import (
var assets embed.FS var assets embed.FS
var templates = map[string]string{ var templates = map[string]string{
"daily": "templates/daily.md.tmpl",
"hourly": "templates/hourly.md.tmpl", "hourly": "templates/hourly.md.tmpl",
"today": "templates/today.md.tmpl", "today": "templates/today.md.tmpl",
"tomorrow": "templates/tomorrow.md.tmpl", "tomorrow": "templates/tomorrow.md.tmpl",
} }
var schemas = map[string]string{ var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json", "hourly": "schemas/hourly.generated_text.schema.json",
"today": "schemas/today.generated_text.schema.json", "today": "schemas/today.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json", "tomorrow": "schemas/tomorrow.generated_text.schema.json",

View File

@@ -2,6 +2,7 @@ package reporttemplate
import ( import (
"encoding/json" "encoding/json"
"os"
"strings" "strings"
"testing" "testing"
) )
@@ -30,6 +31,18 @@ func TestTomorrowTemplateLookup(t *testing.T) {
} }
} }
func TestDailyTemplateLookup(t *testing.T) {
source, err := Template("daily")
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 TestTodayTemplateLookup(t *testing.T) { func TestTodayTemplateLookup(t *testing.T) {
source, err := Template("today") source, err := Template("today")
if err != nil { if err != nil {
@@ -84,6 +97,37 @@ func TestTomorrowSchemaLookup(t *testing.T) {
} }
} }
func TestDailySchemaLookup(t *testing.T) {
data, err := Schema("daily")
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 TestTodaySchemaLookup(t *testing.T) { func TestTodaySchemaLookup(t *testing.T) {
data, err := Schema("today") data, err := Schema("today")
if err != nil { if err != nil {
@@ -351,6 +395,85 @@ func TestRenderTomorrow(t *testing.T) {
}) })
} }
func TestRenderDaily(t *testing.T) {
rendered, err := Render("daily", testDailyRenderContext{
Report: testDailyReportContext{
Title: "Monday's Weather",
ForecastDateLabel: "Monday, June 15, 2026",
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
},
GeneratedText: testDailyGeneratedText{
Summary: "The selected day 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: testDailyModules{
Dayparts: []testDailyDaypart{
{
Key: "morning",
Summary: testDaypartSummary{
DisplayName: "Morning",
TemperatureTrend: "rising",
TemperatureStartPhraseF: "upper 50s",
TemperatureEndPhraseF: "upper 60s",
DominantConditionDisplay: "Sunny",
DominantConditionLower: "sunny",
},
},
{
Key: "afternoon",
Summary: testDaypartSummary{
DisplayName: "Afternoon",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "upper 70s",
DominantConditionDisplay: "Showers",
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",
"The selected day starts dry before showers return later in the day.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **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",
})
}
func TestRenderToday(t *testing.T) { func TestRenderToday(t *testing.T) {
rendered, err := Render("today", testTodayRenderContext{ rendered, err := Render("today", testTodayRenderContext{
Report: testTodayReportContext{ Report: testTodayReportContext{
@@ -532,17 +655,29 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
} }
func TestUnknownAssetsReturnActionableErrors(t *testing.T) { func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
t.Fatalf("Template() error = %v, want unknown template", err) t.Fatalf("Template() error = %v, want unknown template", err)
} }
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) { if _, err := Schema("missing"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "missing"`) {
t.Fatalf("Schema() error = %v, want unknown schema", err) t.Fatalf("Schema() error = %v, want unknown schema", err)
} }
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { if _, err := Render("missing", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
t.Fatalf("Render() error = %v, want unknown template", err) t.Fatalf("Render() error = %v, want unknown template", err)
} }
} }
func TestDailyPromptAssetExists(t *testing.T) {
data, err := os.ReadFile("prompts/daily.generated_text.md")
if err != nil {
t.Fatalf("read Daily prompt asset: %v", err)
}
for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} {
if !strings.Contains(string(data), want) {
t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data))
}
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) { func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}}) _, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
if err == nil { if err == nil {
@@ -571,6 +706,12 @@ type testTodayRenderContext struct {
Modules testTodayModules Modules testTodayModules
} }
type testDailyRenderContext struct {
Report testDailyReportContext
GeneratedText testDailyGeneratedText
Modules testDailyModules
}
type testReportContext struct { type testReportContext struct {
Title string Title string
LocationName string LocationName string
@@ -597,6 +738,12 @@ type testTodayReportContext struct {
GeneratedAtLabel string GeneratedAtLabel string
} }
type testDailyReportContext struct {
Title string
ForecastDateLabel string
GeneratedAtLabel string
}
type testTomorrowGeneratedText struct { type testTomorrowGeneratedText struct {
Summary string Summary string
ForecastDiscussion []string ForecastDiscussion []string
@@ -604,6 +751,13 @@ type testTomorrowGeneratedText struct {
Confidence string Confidence string
} }
type testDailyGeneratedText struct {
Summary string
ForecastDiscussion []string
PrecipitationTiming string
Confidence string
}
type testModules struct { type testModules struct {
CurrentConditions *testCurrentConditions CurrentConditions *testCurrentConditions
HourlyForecast *testHourlyForecast HourlyForecast *testHourlyForecast
@@ -627,6 +781,11 @@ type testTodayModules struct {
TodayPlanning *testTodayPlanning TodayPlanning *testTodayPlanning
} }
type testDailyModules struct {
Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming
}
type testTodayPlanning struct { type testTodayPlanning struct {
MorningReadiness []string MorningReadiness []string
CommuteSchoolWorkdayConcerns []string CommuteSchoolWorkdayConcerns []string
@@ -639,6 +798,11 @@ type testTomorrowDaypart struct {
Summary testDaypartSummary Summary testDaypartSummary
} }
type testDailyDaypart struct {
Key string
Summary testDaypartSummary
}
type testDaypartSummary struct { type testDaypartSummary struct {
DisplayName string DisplayName string
TempRangeF string TempRangeF string

View File

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

View File

@@ -0,0 +1,24 @@
# {{ .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 }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ 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

@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.modules.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"), filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.data_package.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"), filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.render.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.distributor.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"), filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.md"),
} { } {
if !strings.Contains(pathsString(paths), want) { if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want) t.Fatalf("paths = %#v, want component %q", paths, want)
@@ -49,6 +49,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
validDate string validDate string
runID string runID string
}{ }{
{
name: "daily",
resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"),
group: "daily",
validDate: "2026-05-29",
runID: "20260529T100000.000000000Z_daily",
},
{ {
name: "hourly", name: "hourly",
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"), resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
@@ -243,9 +250,12 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
} }
for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} { if decoded.GeneratedTextSchemaID != "daily" {
t.Fatalf("GeneratedTextSchemaID = %q, want daily", decoded.GeneratedTextSchemaID)
}
for _, unexpected := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
if strings.Contains(string(data), unexpected) { if strings.Contains(string(data), unexpected) {
t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data)) t.Fatalf("metadata JSON includes unsaved generated-text path field %q:\n%s", unexpected, string(data))
} }
} }
} }
@@ -547,9 +557,10 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved {
if err != nil { if err != nil {
t.Fatalf("parse time: %v", err) t.Fatalf("parse time: %v", err)
} }
resolved, err := report.DefaultRegistry().Resolve(report.DailyToday, report.ResolveRequest{ resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
Date: now,
}) })
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)