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
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 tomorrow [--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,
preflight artifact, managed Markdown report, and metadata under the configured
workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate today`,
`generate tomorrow`, and `generate hourly` write managed generated-text artifacts,
validate structured text from Scriptorium, and render the managed Markdown
report from embedded templates. `generate hourly` 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 bounds with `--start` and
`--end`.
`generate daily` remains the existing Daily Today report. It is separate from
`generate today`; the commands use different report IDs, artifact groups,
prompts, and config override keys.
notification uses the managed report path, not the extra copy. `generate daily`,
`generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is fetched. `generate hourly`
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
bounds with `--start` and `--end`.
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
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`.
- `--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`.
- `--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`.
- `--end TIME`: required end time for `generate storm`.
- `--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 prior 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

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`,
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
are also valid, including `daily_today`, `three_day_outlook`,
`weekend_outlook`, and `storm_report`. Hyphens and underscores are treated
equivalently in report keys.
are also valid, including `three_day_outlook`, `weekend_outlook`, and
`storm_report`. Hyphens and underscores are treated equivalently in report
keys. Retired report keys are not supported.
`reports.today` applies only to the Today Report. `reports.daily` and
`reports.daily_today` apply only to the existing Daily Today report.
`reports.today` applies only to the Today Report. `reports.daily` applies only
to the dated Daily Report.
Each report entry supports:
@@ -211,6 +211,7 @@ reports:
sections:
- short_term
- spc_convective_discussion
- daily_planning
- hourly_forecast
today:
deterministic_modules:

View File

@@ -135,14 +135,16 @@ failure context.
## Batch Workflow
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a
JSON summary to stdout, and returns an aggregate error when any report failed.
When notification is enabled, each successfully generated report is notified
independently. Notification failure 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.
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
batches. Batch output copy names come from report definitions. Batch generation
continues independent reports after a failure, records each result, writes
compact status lines to stderr, emits a JSON summary to stdout, and returns an
aggregate error when any report failed. When notification is enabled, each
successfully generated report is notified independently. Notification failure
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

View File

@@ -33,11 +33,16 @@ Outputs:
`weather_story`
- `module.Output` values for derived stanzas:
`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
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_planning` and `hourly_forecast`; those outputs feed the Tomorrow
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
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
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`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
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
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Today Report, Tomorrow Report, and Hourly
Report.
assets. The implemented contracts are Daily render context, Today Report,
Tomorrow Report, and Hourly Report.
## Inputs And Outputs
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`
- a module snapshot from `internal/module`
- validated generated text
Outputs:
- typed `Daily` generated text
- typed `Today` generated text
- typed `Tomorrow` generated text
- typed `Hourly` generated text
- normalized stable JSON for validated generated text
- typed `DailyRenderContext` values for `internal/reporttemplate`
- typed `TodayRenderContext` values for `internal/reporttemplate`
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use
`generated_text_template`
The 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:
```json
@@ -96,8 +111,8 @@ JSON when blank.
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required fields fail after trimming whitespace.
- Today and Tomorrow forecast discussion fails when no nonblank paragraphs
remain.
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
paragraphs remain.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
@@ -109,6 +124,7 @@ JSON when blank.
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/daily_test.go`
- `internal/generatedtext/today_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go`

View File

@@ -44,10 +44,33 @@ The registry recognizes these IDs:
- `outdoor_windows`
- `today_planning`
- `tomorrow_planning`
- `daily_planning`
Every registered module has a builder. Report composition entries that refer to
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
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
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` 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
day.
The output uses the same shape as Tomorrow planning:
The output uses this shape:
- `morning_readiness`
- `commute`
- `commute_school_workday_concerns`
- `outdoor_planning`
- `things_to_watch`
- `confidence`
- `late_day_change_watch`
The type is `briefing.TodayPlanningModule`; it is independent from
`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
timestamps where applicable.
Today Report, Tomorrow Report, and Hourly Report module snapshots use the same
package schema and categories when converted into prompt input. The default
hourly module list places
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
use the same package schema and categories when converted into prompt input.
The default hourly module list places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under
`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
package.
The default Today and Tomorrow module lists include civil-day summary stanzas,
planning stanzas, and `hourly_forecast` in the data package before structured
GeneratedText is requested from Scriptorium. Today uses `today_planning`;
Tomorrow uses `tomorrow_planning`.
The default Daily, Today, and Tomorrow module lists include civil-day summary
stanzas, planning stanzas, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium. Daily uses
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
`tomorrow_planning`.
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.
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`three-day`, `weekend`, and `storm`. Config keys also accept underscore and
legacy descriptive aliases such as `daily_today`, `three_day_outlook`,
`three-day`, `weekend`, and `storm`. Config keys also accept selected
underscore and descriptive aliases such as `three_day_outlook`,
`weekend_outlook`, and `storm_report`.
`daily` resolves to the existing Daily Today report ID `daily_today`.
`today` resolves to the independent Today report ID `today`. `reports.today`
is not an alias for `reports.daily` or `reports.daily_today`.
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
independent Today report ID `today`. `reports.today` is not an alias for
`reports.daily`, and retired report keys are not supported.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Today Report,
Tomorrow Report, and Hourly Report declare `generated_text_template`; the app
uses their template and schema identifiers to validate generated text and render
embedded Markdown templates.
Their template and structured-text schema identifiers are empty. Daily Report,
Today Report, Tomorrow Report, and Hourly Report declare
`generated_text_template`; the app uses their template and schema identifiers
to validate generated text and render embedded Markdown templates.
## Reports
| 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 |
| 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 |
@@ -59,8 +59,8 @@ All report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Report covers the selected local civil day and requires an explicit
date.
- Today Report covers the selected local civil day, or the current local civil
day when no date override is supplied.
- 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
output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `today`, `tomorrow`, and `hourly`,
canonical report IDs such as `daily_today`, or descriptive names such as
`three_day_outlook`.
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`.
## Batch Membership
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.
## State And App Usage

View File

@@ -6,8 +6,8 @@ in `internal/reporttemplate`.
## Purpose
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The implemented template contracts are Today
Report, Tomorrow Report, and Hourly Report.
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
Tomorrow, and Hourly.
The package embeds assets from:
@@ -31,13 +31,13 @@ Outputs:
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
The implemented template IDs are `today`, `tomorrow`, and `hourly`. The
implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by
matching `*.generated_text.schema.json` files.
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
`hourly`, backed by matching `*.generated_text.schema.json` files.
The Today generated-text prompt source is
`internal/reporttemplate/prompts/today.generated_text.md`, selected by prompt
ID `weather.today_generated_text`.
Generated-text prompt sources are maintained under
`internal/reporttemplate/prompts/`, including Daily's
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
## Boundaries
@@ -53,7 +53,7 @@ render-context builder, and embedded assets.
## 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
time
@@ -62,10 +62,13 @@ Today, Tomorrow, and Hourly rendering use typed render contexts with:
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Today and Tomorrow additionally expose forecast-date labels, ordered daypart
forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph
forecast discussion generated-text slot. The ordered daypart slice is built in
Go so templates do not range over maps.
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
daypart forecast rows, daily/daypart summaries, planning facts, and a
multi-paragraph forecast discussion generated-text slot. The ordered daypart
slice is built in Go so templates do not range over maps.
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
fail rendering instead of producing incomplete Markdown.
@@ -78,10 +81,11 @@ to write for each generated-text prompt. Hourly requires:
- `summary`
- `forecast_discussion`
Today and Tomorrow require `summary` and a nonempty `forecast_discussion`
array. All generated-text schemas allow optional `precipitation_timing` and
`confidence`, and reject additional properties. Weather truth remains in module
outputs; GeneratedText is limited to prose slots consumed by the template.
Daily, Today, and Tomorrow require `summary` and a nonempty
`forecast_discussion` array. All generated-text schemas allow optional
`precipitation_timing` and `confidence`, and reject additional properties.
Weather truth remains in module outputs; GeneratedText is limited to prose
slots consumed by the template.
## 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
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.
- Today Report compares with prior Today Report snapshots for the same valid
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
managed Markdown report path.
`generate today`, `generate tomorrow`, and `generate hourly` use the
generated-text-template workflow. They run structured `scriptorium run` to raw
GeneratedText JSON, validate the structured text, save a render context, and
render the managed Markdown report from embedded templates. `generate today`
covers the selected or current local civil day. `generate hourly` covers the
six-hour rolling period from generation time in the effective report timezone
and is not part of scheduled morning or evening batches.
`generate daily` remains the existing Daily Today report and is independent of
`generate today`.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run`
to raw GeneratedText JSON, validate the structured text, save a render context,
and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening
batches.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved.
@@ -69,6 +68,10 @@ workspace/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
today/
YYYY-MM-DD/
<run_id>.modules.json
@@ -196,7 +199,7 @@ period do not overwrite each other.
RunIDs are based on generation time plus report ID:
```text
20260529T100000.123456789Z_daily_today
20260529T100000.123456789Z_daily
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
the valid-period start date, artifact group, and RunID. Distributor owns
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`,
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
Markdown or YAML text.
Daily Today compares with prior Daily Today snapshots for the same valid local
date. Today Report compares with prior Today Report snapshots for the same
valid local date. Tomorrow Report compares with prior Tomorrow Report snapshots
for the same valid local date. 3-Day Outlook compares with prior compatible
3-Day snapshots for the same valid local date. Weekend Outlook compares with
prior compatible Weekend snapshots for the same weekend window. Hourly Report
and Storm Report leave Recent Changes empty.
Daily Report compares with prior Daily Report snapshots for the same valid
local date. Today Report compares with prior Today Report snapshots for the
same valid local date. Tomorrow Report compares with prior Tomorrow Report
snapshots for the same valid local date. 3-Day Outlook compares with prior
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
compares with prior compatible Weekend snapshots for the same weekend window.
Hourly Report and Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`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.
- If `scriptorium run` exits nonzero after writing a report, the managed report
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
generated-text JSON, validated generated text, and render context. Metadata
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:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
Templates are rendered from structured contexts such as `TodayRenderContext`,
`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection,
derivation, module execution, generated text validation, and artifact paths are
handled before template rendering.
Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text
validation, and artifact paths are handled before template rendering.
## Editing Rules
@@ -115,6 +116,37 @@ Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
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
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
```
Template render tests currently exercise the hourly template through
`internal/generatedtext/render_context_test.go` and
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
templates through `internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`.

View File

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

View File

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

View File

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

View File

@@ -1107,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
}
switch reportID {
case report.DailyToday, report.Today, report.Tomorrow:
case report.Daily, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds)

View File

@@ -105,6 +105,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
ExitCode: 0,
OutputPath: "",
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
runBody: "# Daily Report\n\nRain this morning.\n",
}
outputPath := filepath.Join(t.TempDir(), "daily.md")
@@ -128,25 +133,28 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
}
if renderer.runCalls != 1 {
t.Fatalf("run calls = %d, want 1", renderer.runCalls)
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.renderRequest.PromptID != "weather.daily_report" {
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID)
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.renderRequest.PromptID != "weather.daily_generated_text" {
t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID)
}
if 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 {
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath)
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
if renderer.runRequest.OutputPath != result.ReportPath {
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
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)
}
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)
if err != nil {
t.Fatalf("read module snapshot: %v", err)
@@ -252,7 +260,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if err != nil {
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))
}
}
@@ -754,8 +762,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Report: ReportThreeDay,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
@@ -764,7 +771,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
Renderer: successfulRenderer("# 3-Day Outlook\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
@@ -1125,7 +1132,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
if req.PipelineID != "weatherreporter.daily" {
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)
}
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
@@ -1208,10 +1215,9 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
{
name: "Run",
renderer: &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"},
runErr: errors.New("run failed"),
runBody: "# Daily Report\n",
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"},
structuredRunErr: errors.New("run failed"),
},
},
}
@@ -1350,12 +1356,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{
structuredRunResult: &scriptorium.StructuredRunResult{
Stderr: "validation failed",
ExitCode: 2,
},
runErr: errors.New("scriptorium run exited with code 2: validation failed"),
runBody: "# Daily Report\n",
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
}
_, err = GenerateReport(context.Background(), ReportRequest{
@@ -1366,8 +1371,8 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
if err == nil {
t.Fatal("GenerateReport() error = nil, want run error")
}
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
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)
if err != nil {
@@ -1380,8 +1385,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
}
if _, err := os.Stat(paths.RenderedReport); err != nil {
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err)
if _, err := os.Stat(paths.GeneratedTextRaw); err != nil {
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) {
path := filepath.Join(t.TempDir(), "config.yml")
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."}`
}
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 {
t.Helper()
cfg.Workspace.Root = t.TempDir()
@@ -2835,9 +2863,10 @@ func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metada
func successfulRenderer(body string) *recordingRenderer {
return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: body,
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
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" {
body = validTomorrowGeneratedTextJSON()
}
if req.PromptID == "weather.daily_generated_text" {
body = validDailyGeneratedTextJSON()
}
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
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) {
r.structuredRunCalls++
r.structuredRunRequest = req
if r.structuredRunBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil {
body := r.structuredRunBody
if body == "" {
body = validGeneratedTextJSONForPrompt(req.PromptID)
}
if body != "" {
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err
}
}
@@ -2952,3 +2988,18 @@ func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.Str
}
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)
}
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)
}
if value.Location == nil || value.Location.Name != "Brentwood" {
@@ -395,7 +395,7 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{
Definition: definition,
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) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil {
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
@@ -154,7 +154,7 @@ func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
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) {
registry := MustDefaultModuleRegistry()
ctx := todayModuleContext()
@@ -382,7 +453,7 @@ func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
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) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
@@ -413,7 +484,7 @@ func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
@@ -505,7 +576,7 @@ func derivedModuleContext(id report.ID) ModuleContext {
}
func todayModuleContext() ModuleContext {
ctx := derivedModuleContext(report.DailyToday)
ctx := derivedModuleContext(report.Daily)
ctx.Resolved.Definition = report.Definition{
ID: report.Today,
Name: "Today Report",
@@ -514,6 +585,16 @@ func todayModuleContext() ModuleContext {
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 {
hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{

View File

@@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, 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}
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{
{
ID: module.Metadata,
@@ -276,7 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
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,
Builder: buildNarrativeForecastModule,
},
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
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,
Builder: buildHourlyForecastModule,
},
@@ -295,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
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,
Builder: buildDerivedDailySummaryModule,
},
@@ -392,5 +392,14 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty,
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) {
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"`) {
t.Fatalf("error = %v, want unknown module", err)
}
@@ -85,7 +85,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
})
@@ -106,8 +106,8 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
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"`) {
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 {
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) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
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) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
@@ -158,6 +173,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
module.OutdoorWindows,
module.TodayPlanning,
module.TomorrowPlanning,
module.DailyPlanning,
} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
@@ -188,7 +204,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
})
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) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
})

View File

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

View File

@@ -28,6 +28,12 @@ type TomorrowPlanning struct {
OvernightChangeWatch []string
}
type morningCommuteOvernightPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type TodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
@@ -99,7 +105,16 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
}
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")
if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)

View File

@@ -17,7 +17,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage:
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 tomorrow [--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 {
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 == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {

View File

@@ -23,7 +23,7 @@ func TestRunHelpLongFlag(t *testing.T) {
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)
}
if !strings.Contains(output.stdout, "generate today") {
@@ -251,7 +251,7 @@ func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
Failed: 1,
Reports: []app.BatchReportResult{
{
ReportID: "daily_today",
ReportID: "daily",
Status: "succeeded",
OutputPath: "/tmp/daily.md",
NotificationStatus: "accepted",
@@ -299,11 +299,11 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
Failed: 1,
Reports: []app.BatchReportResult{
{
ReportID: "daily_today",
ReportID: "daily",
Status: "failed",
Error: "notify report daily_today: upload failed: [redacted]",
Error: "notify report daily: upload failed: [redacted]",
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 {
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))
}
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 {
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))
}
if !strings.Contains(string(data), "location:") ||
@@ -571,6 +571,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if len(reportMatches) != 1 {
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) {
@@ -975,14 +983,44 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
}
}
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
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 {
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" {
t.Fatalf("Date = %s, want 2026-05-29", got)
}
@@ -1024,7 +1062,7 @@ func TestResolveGenerateTodayDate(t *testing.T) {
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
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 {
t.Fatalf("resolveGenerate() error = %v", err)
}
@@ -1299,6 +1337,21 @@ JSON
],
"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
printf 'wrote generated text\n' >&2
exit 0
@@ -1345,6 +1398,21 @@ if [ "$1" = "run" ]; then
"precipitation_timing": "A cold front is moving into the region.",
"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
printf 'wrote generated text\n' >&2
exit 0

View File

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

View File

@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
switch req.Resolved.Definition.ID {
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)
if err != nil {
return DerivedFacts{}, err

View File

@@ -56,7 +56,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
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{
Resolved: resolved,
Timezone: location.String(),
@@ -252,8 +252,8 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
wantDiscussion []string
}{
{
name: "daily today",
resolved: resolveForTest(t, report.DailyToday, now, location),
name: "daily",
resolved: resolveForTest(t, report.Daily, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
@@ -322,7 +322,7 @@ func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
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.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 {
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 {
t.Fatalf("resolve %s: %v", id, err)
}

View File

@@ -12,10 +12,12 @@ import (
const (
schemaIDHourly = "hourly"
schemaIDDaily = "daily"
schemaIDToday = "today"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDDaily = "daily"
templateIDToday = "today"
templateIDTomorrow = "tomorrow"
)
@@ -46,6 +48,12 @@ var catalog = []catalogEntry{
validate: validateHourly,
renderContextBuilder: buildHourlyContext,
},
{
schemaID: schemaIDDaily,
templateID: templateIDDaily,
validate: validateDaily,
renderContextBuilder: buildDailyContext,
},
{
schemaID: schemaIDToday,
templateID: templateIDToday,
@@ -142,6 +150,10 @@ func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateDaily(data []byte) (any, []byte, error) {
return ValidateDaily(data)
}
func validateToday(data []byte) (any, []byte, error) {
return ValidateToday(data)
}
@@ -158,6 +170,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
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) {
today, ok := generated.(Today)
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) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
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."]`) {
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) {
@@ -230,4 +279,46 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if !strings.Contains(err.Error(), `requires today generated text`) {
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
}
type DailyRenderContext struct {
Report DailyReportContext
GeneratedText Daily
Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
@@ -124,6 +132,39 @@ type TomorrowDaypartContext struct {
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) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
@@ -156,6 +197,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}, 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) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
@@ -225,6 +301,78 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, 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) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
@@ -470,6 +618,14 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
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) {
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
}
@@ -500,6 +656,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod
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 {
orderedRows := orderedDaypartRows(dayparts, ordered)
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) {
metadata := testTomorrowMetadata()
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 {
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
return briefing.Metadata{
@@ -446,6 +592,10 @@ func testTomorrowDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
func testDailyDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
func testTodayDerived() facts.DerivedFacts {
return testCivilDayDerived()
}
@@ -605,6 +755,154 @@ func testTodaySnapshot(t *testing.T) module.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 {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{

View File

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

View File

@@ -100,3 +100,11 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
_ = SPCConvectiveOutlooksOptions{}
_ = 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.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.DailyPlanning): categoryDerivedSummaries,
string(module.TodayPlanning): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,

View File

@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if 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)
}
if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
if pkg.Report.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
}
if pkg.Report.CurrentLocalDate != "2026-05-29" {
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) {
data := []byte(`
schema_version: weatherreporter.data_package.v2
run_id: 20260529T100000Z_daily_today
run_id: 20260529T100000Z_daily
report:
id: daily_today
prompt_id: weather.daily_report
id: daily
prompt_id: weather.daily_generated_text
generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago
current_local_date: "2026-05-29"
@@ -264,7 +264,7 @@ report:
end: 2026-05-30T05:00:00Z
briefing:
metadata:
run_id: 20260529T100000Z_daily_today
run_id: 20260529T100000Z_daily
raw_data:
alert_digest:
checked: true
@@ -283,10 +283,10 @@ func validBuildRequest(t *testing.T) BuildRequest {
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return BuildRequest{
Metadata: Metadata{
RunID: "20260529T100000Z_daily_today",
ReportID: report.DailyToday,
RunID: "20260529T100000Z_daily",
ReportID: report.Daily,
Variant: "today",
PromptID: "weather.daily_report",
PromptID: "weather.daily_generated_text",
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
@@ -295,7 +295,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
},
},
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.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}},

View File

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

View File

@@ -12,13 +12,13 @@ import (
type ID string
const (
DailyToday ID = "daily_today"
Today ID = "today"
Tomorrow ID = "tomorrow"
Hourly ID = "hourly"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
Daily ID = "daily"
Today ID = "today"
Tomorrow ID = "tomorrow"
Hourly ID = "hourly"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
)
type ComparisonStrategy string

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ type Registry struct {
func DefaultRegistry() Registry {
definitions := []Definition{
dailyTodayDefinition(),
dailyDefinition(),
todayDefinition(),
tomorrowDefinition(),
hourlyDefinition(),
@@ -75,7 +75,7 @@ func (r Registry) MustLookup(id ID) 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))
for _, id := range ids {
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 templates = map[string]string{
"daily": "templates/daily.md.tmpl",
"hourly": "templates/hourly.md.tmpl",
"today": "templates/today.md.tmpl",
"tomorrow": "templates/tomorrow.md.tmpl",
}
var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json",
"today": "schemas/today.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json",

View File

@@ -2,6 +2,7 @@ package reporttemplate
import (
"encoding/json"
"os"
"strings"
"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) {
source, err := Template("today")
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) {
data, err := Schema("today")
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) {
rendered, err := Render("today", testTodayRenderContext{
Report: testTodayReportContext{
@@ -532,17 +655,29 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(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)
}
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)
}
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)
}
}
func TestDailyPromptAssetExists(t *testing.T) {
data, err := os.ReadFile("prompts/daily.generated_text.md")
if err != nil {
t.Fatalf("read Daily prompt asset: %v", err)
}
for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} {
if !strings.Contains(string(data), want) {
t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data))
}
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
if err == nil {
@@ -571,6 +706,12 @@ type testTodayRenderContext struct {
Modules testTodayModules
}
type testDailyRenderContext struct {
Report testDailyReportContext
GeneratedText testDailyGeneratedText
Modules testDailyModules
}
type testReportContext struct {
Title string
LocationName string
@@ -597,6 +738,12 @@ type testTodayReportContext struct {
GeneratedAtLabel string
}
type testDailyReportContext struct {
Title string
ForecastDateLabel string
GeneratedAtLabel string
}
type testTomorrowGeneratedText struct {
Summary string
ForecastDiscussion []string
@@ -604,6 +751,13 @@ type testTomorrowGeneratedText struct {
Confidence string
}
type testDailyGeneratedText struct {
Summary string
ForecastDiscussion []string
PrecipitationTiming string
Confidence string
}
type testModules struct {
CurrentConditions *testCurrentConditions
HourlyForecast *testHourlyForecast
@@ -627,6 +781,11 @@ type testTodayModules struct {
TodayPlanning *testTodayPlanning
}
type testDailyModules struct {
Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming
}
type testTodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
@@ -639,6 +798,11 @@ type testTomorrowDaypart struct {
Summary testDaypartSummary
}
type testDailyDaypart struct {
Key string
Summary testDaypartSummary
}
type testDaypartSummary struct {
DisplayName 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{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.modules.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.data_package.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.render.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.distributor.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.md"),
} {
if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want)
@@ -49,6 +49,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
validDate 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",
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") {
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) {
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 {
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,
Location: location,
Date: now,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)