Compare commits
6 Commits
7b4c73d1e6
...
63dfc0b55a
| Author | SHA1 | Date | |
|---|---|---|---|
| 63dfc0b55a | |||
| 1ddc33eb17 | |||
| 4a0238909b | |||
| 3d5f71e72d | |||
| 8ff5c44324 | |||
| 4e704e4f51 |
@@ -9,7 +9,7 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
35
docs/cli.md
35
docs/cli.md
@@ -6,20 +6,22 @@ batches, and inspects stored artifacts.
|
||||
## Shortest Useful Command
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
||||
distributor notification is enabled in configuration, the command also uploads
|
||||
the managed Markdown report after final metadata is saved.
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs structured
|
||||
`scriptorium run`, validates generated text, renders the embedded Today
|
||||
template, and writes an extra Markdown copy to `./today.md`. If distributor
|
||||
notification is enabled in configuration, the command also uploads the managed
|
||||
Markdown report after final metadata is saved.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
@@ -38,15 +40,19 @@ 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
|
||||
tomorrow` and `generate hourly` write managed generated-text artifacts,
|
||||
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`.
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
`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.
|
||||
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
@@ -70,7 +76,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`; defaults to the current local date in the configured timezone.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily` and `generate today`; defaults 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.
|
||||
@@ -84,6 +90,8 @@ are no distributor-specific CLI flags.
|
||||
## Common Workflows
|
||||
|
||||
```sh
|
||||
weatherreporter generate today --out ./today.md
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate hourly
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
@@ -97,11 +105,12 @@ weatherreporter run evening --out-dir ./reports
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||
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 modules 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
|
||||
@@ -181,11 +181,14 @@ snapshot exists and a threshold is crossed.
|
||||
`reports` optionally overrides the ordered deterministic modules declared by
|
||||
report definitions. Omit a report entry to use its default module order.
|
||||
|
||||
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
||||
`weekend`, and `storm`. Canonical report IDs 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.
|
||||
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.
|
||||
|
||||
`reports.today` applies only to the Today Report. `reports.daily` and
|
||||
`reports.daily_today` apply only to the existing Daily Today report.
|
||||
|
||||
Each report entry supports:
|
||||
|
||||
@@ -209,6 +212,22 @@ reports:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- hourly_forecast
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- area_forecast_discussion
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
- today_planning
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
|
||||
@@ -134,7 +134,7 @@ failure context.
|
||||
|
||||
## Batch Workflow
|
||||
|
||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
|
||||
@@ -33,7 +33,7 @@ Outputs:
|
||||
`weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
`outdoor_windows`, `today_planning`, and `tomorrow_planning`
|
||||
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
@@ -42,13 +42,21 @@ 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.
|
||||
|
||||
Today Report supports the Daily-style civil-day modules plus `today_planning`
|
||||
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
|
||||
package and embedded Markdown template.
|
||||
|
||||
`today_planning` is a Today-specific deterministic planning stanza with
|
||||
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||
late-day change-watch fields. It is compatible with `report.Today` only.
|
||||
|
||||
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`, or
|
||||
`tomorrow_planning`.
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
||||
`today_planning`, or `tomorrow_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
|
||||
|
||||
@@ -9,22 +9,25 @@ 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 Tomorrow Report and Hourly Report.
|
||||
assets. The implemented contracts are Today Report, Tomorrow Report, and Hourly
|
||||
Report.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
|
||||
- raw GeneratedText JSON for Today, Tomorrow Report, or Hourly Report
|
||||
- report metadata from `internal/briefing`
|
||||
- a module snapshot from `internal/module`
|
||||
- validated generated text
|
||||
|
||||
Outputs:
|
||||
|
||||
- typed `Today` generated text
|
||||
- typed `Tomorrow` generated text
|
||||
- typed `Hourly` generated text
|
||||
- normalized stable JSON for validated generated text
|
||||
- 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
|
||||
@@ -45,6 +48,20 @@ The hourly generated text JSON accepts:
|
||||
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||
JSON when blank.
|
||||
|
||||
The Today generated text JSON accepts the same public fields and validation
|
||||
rules as Tomorrow. It is selected by the active Today report definition through
|
||||
schema ID `today`, template ID `today`, and prompt ID
|
||||
`weather.today_generated_text`:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "string",
|
||||
"forecast_discussion": ["string"],
|
||||
"precipitation_timing": "string",
|
||||
"confidence": "string"
|
||||
}
|
||||
```
|
||||
|
||||
The Tomorrow generated text JSON accepts:
|
||||
|
||||
```json
|
||||
@@ -79,7 +96,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.
|
||||
- Tomorrow forecast discussion fails when no nonblank paragraphs remain.
|
||||
- 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.
|
||||
@@ -91,6 +109,7 @@ JSON when blank.
|
||||
Inspect:
|
||||
|
||||
- `internal/generatedtext/hourly_test.go`
|
||||
- `internal/generatedtext/today_test.go`
|
||||
- `internal/generatedtext/tomorrow_test.go`
|
||||
- `internal/generatedtext/catalog_test.go`
|
||||
- `internal/generatedtext/render_context_test.go`
|
||||
|
||||
@@ -42,11 +42,34 @@ The registry recognizes these IDs:
|
||||
- `spc_convective_discussion`
|
||||
- `weather_story`
|
||||
- `outdoor_windows`
|
||||
- `today_planning`
|
||||
- `tomorrow_planning`
|
||||
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## Today Composition
|
||||
|
||||
The default Today 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. `hourly_forecast`
|
||||
14. `today_planning`
|
||||
|
||||
The embedded Today template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
|
||||
## Tomorrow Composition
|
||||
|
||||
The default Tomorrow Report module order is:
|
||||
@@ -69,6 +92,24 @@ The default Tomorrow Report module order is:
|
||||
The embedded Tomorrow template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
|
||||
## Today Planning
|
||||
|
||||
`today_planning` emits current-day planning facts for Today Report. Its output
|
||||
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:
|
||||
|
||||
- `morning_readiness`
|
||||
- `commute`
|
||||
- `outdoor_planning`
|
||||
- `things_to_watch`
|
||||
- `confidence`
|
||||
|
||||
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||
`briefing.TomorrowPlanningModule`.
|
||||
|
||||
## Hourly Composition
|
||||
|
||||
The default Hourly Report module order is:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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,9 +78,10 @@ include civil-day summary stanzas. Generated-text and render context artifacts
|
||||
are produced later in app orchestration and are not part of the YAML data
|
||||
package.
|
||||
|
||||
The default Tomorrow module list includes civil-day summary stanzas,
|
||||
`tomorrow_planning`, and `hourly_forecast` in the data package before
|
||||
structured GeneratedText is requested from Scriptorium.
|
||||
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`.
|
||||
|
||||
Current categories are:
|
||||
|
||||
|
||||
@@ -28,22 +28,27 @@ Each report definition declares:
|
||||
- default ordered module composition
|
||||
|
||||
Report-owned helpers map public command names and config keys to report IDs.
|
||||
The generate command names are `daily`, `tomorrow`, `hourly`, `three-day`,
|
||||
`weekend`, and `storm`. Config keys also accept underscore and legacy
|
||||
descriptive aliases such as `daily_today`, `three_day_outlook`,
|
||||
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`,
|
||||
`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`.
|
||||
|
||||
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||
Their template and structured-text schema identifiers are empty. Tomorrow
|
||||
Report and Hourly Report declare `generated_text_template`; the app uses their
|
||||
template and schema identifiers to validate generated text and render embedded
|
||||
Markdown templates.
|
||||
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.
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
@@ -56,6 +61,8 @@ All report definitions are eligible for generation.
|
||||
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- 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.
|
||||
- Hourly Report covers the half-open six-hour period from generation time in
|
||||
the effective report timezone. The duration is an internal report constant,
|
||||
@@ -84,15 +91,15 @@ 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 `tomorrow` and `hourly`, canonical
|
||||
report IDs such as `daily_today`, or accepted aliases such as
|
||||
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`.
|
||||
|
||||
## Batch Membership
|
||||
|
||||
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
|
||||
of a scheduled batch.
|
||||
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
|
||||
except on Sunday. Evening batches include Tomorrow Report. Daily Today and
|
||||
Hourly Report are not part of a scheduled batch.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
|
||||
@@ -6,14 +6,18 @@ in `internal/reporttemplate`.
|
||||
## Purpose
|
||||
|
||||
`internal/reporttemplate` owns repository-native report templates and companion
|
||||
GeneratedText JSON schemas. The implemented template contracts are Tomorrow
|
||||
Report and Hourly Report.
|
||||
GeneratedText JSON schemas. The implemented template contracts are Today
|
||||
Report, Tomorrow Report, and Hourly Report.
|
||||
|
||||
The package embeds assets from:
|
||||
|
||||
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||
- `internal/reporttemplate/schemas/*.schema.json`
|
||||
|
||||
Generated-text prompt source files live under
|
||||
`internal/reporttemplate/prompts/`. They are repository assets for prompt
|
||||
registration, not embedded lookup APIs.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
@@ -27,9 +31,13 @@ Outputs:
|
||||
- GeneratedText schema bytes for prompt/schema configuration
|
||||
- rendered Markdown bytes for app orchestration to persist
|
||||
|
||||
The implemented template IDs are `tomorrow` and `hourly`. The implemented
|
||||
schema IDs are also `tomorrow` and `hourly`, backed by
|
||||
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
||||
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 Today generated-text prompt source is
|
||||
`internal/reporttemplate/prompts/today.generated_text.md`, selected by prompt
|
||||
ID `weather.today_generated_text`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -45,7 +53,7 @@ render-context builder, and embedded assets.
|
||||
|
||||
## Template Contracts
|
||||
|
||||
Tomorrow and Hourly rendering use typed render contexts with:
|
||||
Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
||||
|
||||
- report metadata labels such as title, location, valid period, and generation
|
||||
time
|
||||
@@ -54,8 +62,8 @@ 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
|
||||
|
||||
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
||||
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
|
||||
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.
|
||||
|
||||
@@ -70,10 +78,10 @@ to write for each generated-text prompt. Hourly requires:
|
||||
- `summary`
|
||||
- `forecast_discussion`
|
||||
|
||||
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ current report definition.
|
||||
|
||||
- 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 3-Day snapshots for the same valid local
|
||||
|
||||
@@ -10,6 +10,7 @@ Generation commands:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate today
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate hourly
|
||||
weatherreporter generate three-day
|
||||
@@ -23,12 +24,16 @@ 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 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 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 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`.
|
||||
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
Markdown report after report rendering succeeds and final metadata is saved.
|
||||
@@ -42,14 +47,14 @@ weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. When notification is configured, the summary and
|
||||
status lines include notification status, accepted distributor run ID, or
|
||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||
extra Markdown copies using report default filenames such as `daily.md`,
|
||||
extra Markdown copies using report default filenames such as `today.md`,
|
||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||
distributor upload sources.
|
||||
|
||||
@@ -64,6 +69,14 @@ workspace/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
today/
|
||||
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
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
@@ -96,6 +109,9 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
@@ -115,6 +131,9 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
@@ -134,6 +153,9 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
@@ -152,6 +174,8 @@ workspace/
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
today/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
@@ -173,6 +197,7 @@ RunIDs are based on generation time plus report ID:
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
20260529T100000.123456789Z_today
|
||||
```
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
@@ -214,6 +239,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 Today, the default report ID and artifact group values are both `today`,
|
||||
and the batch output filename value is `today.md`.
|
||||
|
||||
Notification happens after final metadata save for generated reports. Weather
|
||||
API, module snapshot, data-package, render preflight, Scriptorium run,
|
||||
@@ -267,11 +294,12 @@ 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. 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.
|
||||
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.
|
||||
@@ -284,10 +312,10 @@ 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.
|
||||
- Hourly generated-text failures 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.
|
||||
- Generated-text failures for 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.
|
||||
- If distributor notification fails, report artifacts and final metadata remain
|
||||
available, but the report or batch command returns nonzero.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
|
||||
@@ -8,13 +8,14 @@ especially generated-text-template reports.
|
||||
|
||||
Templates are Go `text/template` files. The current implemented templates are:
|
||||
|
||||
- `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 `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 `TodayRenderContext`,
|
||||
`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection,
|
||||
derivation, module execution, generated text validation, and artifact paths are
|
||||
handled before template rendering.
|
||||
|
||||
## Editing Rules
|
||||
|
||||
@@ -114,6 +115,37 @@ 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
|
||||
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
|
||||
|
||||
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||
`.Report.Timezone`.
|
||||
|
||||
Today 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`.
|
||||
|
||||
Today uses template ID `today`, generated-text schema ID `today`, and prompt
|
||||
source `internal/reporttemplate/prompts/today.generated_text.md`.
|
||||
|
||||
Today 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.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
|
||||
|
||||
Prefer `.Modules.Dayparts` over ranging through
|
||||
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||
falls back to sorted keys for any unmatched entries.
|
||||
|
||||
## Modules
|
||||
|
||||
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||
|
||||
@@ -187,7 +187,7 @@ Relevant docs: [Operations guide](operations.md),
|
||||
|
||||
## Generated Text Validation Fails
|
||||
|
||||
Symptom: Tomorrow or Hourly generation fails with generated-text decode,
|
||||
Symptom: 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
|
||||
@@ -210,7 +210,7 @@ Relevant docs: [Operations guide](operations.md),
|
||||
|
||||
## Template Rendering Fails
|
||||
|
||||
Symptom: Tomorrow or Hourly generation fails with report template parsing or
|
||||
Symptom: 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
|
||||
|
||||
@@ -89,6 +89,28 @@ reports:
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
- today_planning
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
|
||||
@@ -30,6 +30,7 @@ type ReportKind string
|
||||
|
||||
const (
|
||||
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
|
||||
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
||||
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
||||
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
||||
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
|
||||
@@ -1106,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
}
|
||||
switch reportID {
|
||||
case report.DailyToday, report.Tomorrow:
|
||||
case report.DailyToday, report.Today, report.Tomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
return changes.CompareThreeDay(previous, current, thresholds)
|
||||
|
||||
@@ -597,6 +597,108 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
|
||||
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
|
||||
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
|
||||
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportToday,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "today-copy.md")
|
||||
notifier := &recordingNotifier{
|
||||
result: &NotificationResult{
|
||||
RunID: "distributor-run",
|
||||
Status: "succeeded",
|
||||
UploadStatus: "accepted",
|
||||
PipelineID: "reports",
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
},
|
||||
}
|
||||
renderer := &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||
structuredRunBody: validTodayGeneratedTextJSON(),
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if result.OutputPath != outputPath {
|
||||
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
|
||||
}
|
||||
assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
|
||||
reportData, err := os.ReadFile(result.ReportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed report: %v", err)
|
||||
}
|
||||
copyData, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read output copy: %v", err)
|
||||
}
|
||||
if string(copyData) != string(reportData) {
|
||||
t.Fatalf("output copy differs from managed report")
|
||||
}
|
||||
if len(notifier.requests) != 1 {
|
||||
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
|
||||
}
|
||||
req := notifier.requests[0]
|
||||
if req.ReportID != report.Today {
|
||||
t.Fatalf("notification ReportID = %q, want today", req.ReportID)
|
||||
}
|
||||
if req.ReportPath != result.ReportPath {
|
||||
t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath)
|
||||
}
|
||||
if req.ReportPath == outputPath {
|
||||
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
|
||||
}
|
||||
if req.PipelineID != "weatherreporter.today.today" {
|
||||
t.Fatalf("PipelineID = %q, want Today report and artifact values", req.PipelineID)
|
||||
}
|
||||
if req.BundleID != "today.today.md.today" {
|
||||
t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID)
|
||||
}
|
||||
wantBundlePaths := []string{"2026-05-29/today/today.md"}
|
||||
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
||||
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
|
||||
}
|
||||
notificationData, err := os.ReadFile(result.NotificationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read notification artifact: %v", err)
|
||||
}
|
||||
var notificationArtifact state.DistributorNotificationArtifact
|
||||
if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil {
|
||||
t.Fatalf("decode notification artifact: %v", err)
|
||||
}
|
||||
if notificationArtifact.ReportID != report.Today ||
|
||||
notificationArtifact.PipelineID != "weatherreporter.today.today" ||
|
||||
notificationArtifact.BundleID != "today.today.md.today" ||
|
||||
notificationArtifact.SourcePath != result.ReportPath ||
|
||||
strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") ||
|
||||
notificationArtifact.RunStatus == nil ||
|
||||
!strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("notification artifact = %#v, want Today managed-source notification", notificationArtifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
|
||||
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
||||
notifier.err = errors.New("upload rejected")
|
||||
@@ -1338,6 +1440,93 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
priorResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportToday,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T04:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportToday,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
||||
}
|
||||
renderer := &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||
structuredRunBody: validTodayGeneratedTextJSON(),
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 {
|
||||
t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls)
|
||||
}
|
||||
if result.Metadata.ReportID != report.Today || result.Metadata.Variant != "today" || result.Metadata.GeneratedTextSchemaID != "today" {
|
||||
t.Fatalf("metadata = %#v, want Today generated-text metadata", result.Metadata)
|
||||
}
|
||||
if !strings.Contains(result.Metadata.RunID, "_today") {
|
||||
t.Fatalf("RunID = %q, want Today report ID suffix", result.Metadata.RunID)
|
||||
}
|
||||
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "today", "2026-05-29")) {
|
||||
t.Fatalf("DataPackagePath = %q, want Today artifact group", result.DataPackagePath)
|
||||
}
|
||||
if !strings.Contains(result.ReportPath, filepath.Join("reports", "today")) {
|
||||
t.Fatalf("ReportPath = %q, want Today report group", result.ReportPath)
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("today_planning"); !ok {
|
||||
t.Fatal("today_planning stanza missing")
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); ok {
|
||||
t.Fatal("tomorrow_planning stanza present, want Today-specific planning")
|
||||
}
|
||||
if result.PriorSnapshot == nil || len(result.RecentChanges) == 0 {
|
||||
t.Fatalf("prior=%#v recentChanges=%#v, want Today daily comparison changes", result.PriorSnapshot, result.RecentChanges)
|
||||
}
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
for _, want := range []string{"id: today", "prompt_id: weather.today_generated_text", "today_planning:", "recent_changes:"} {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("data package missing %q:\n%s", want, string(data))
|
||||
}
|
||||
}
|
||||
reportData, err := os.ReadFile(result.ReportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
for _, want := range []string{"# Today's Weather", "Today starts with showers before improving."} {
|
||||
if !strings.Contains(string(reportData), want) {
|
||||
t.Fatalf("today report missing %q:\n%s", want, string(reportData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
@@ -2072,8 +2261,8 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if renderer.runCalls != 2 {
|
||||
t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls)
|
||||
if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 {
|
||||
t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
|
||||
}
|
||||
var failedThreeDay bool
|
||||
for _, item := range result.Reports {
|
||||
@@ -2215,6 +2404,49 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
outputDir := filepath.Join(t.TempDir(), "reports")
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
OutputDir: outputDir,
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 0 || len(result.Reports) != 3 {
|
||||
t.Fatalf("summary = %#v, want successful morning batch", result)
|
||||
}
|
||||
var todayItem *BatchReportResult
|
||||
for i := range result.Reports {
|
||||
if result.Reports[i].ReportID == report.Today {
|
||||
todayItem = &result.Reports[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if todayItem == nil {
|
||||
t.Fatalf("reports = %#v, want Today item", result.Reports)
|
||||
}
|
||||
want := filepath.Join(outputDir, "today.md")
|
||||
if todayItem.OutputPath != want {
|
||||
t.Fatalf("Today OutputPath = %q, want %q", todayItem.OutputPath, want)
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected Today output copy %q: %v", want, err)
|
||||
}
|
||||
if strings.Contains(todayItem.ReportPath, outputDir) {
|
||||
t.Fatalf("Today ReportPath = %q, want managed report path separate from output copy", todayItem.ReportPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
|
||||
definition, err := report.DefaultRegistry().Lookup(report.Hourly)
|
||||
if err != nil {
|
||||
@@ -2316,6 +2548,10 @@ func validTomorrowGeneratedTextJSON() string {
|
||||
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||
}
|
||||
|
||||
func 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 generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
||||
t.Helper()
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
@@ -2671,6 +2907,9 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
|
||||
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
|
||||
r.structuredRunCalls++
|
||||
body := validHourlyGeneratedTextJSON()
|
||||
if req.PromptID == "weather.today_generated_text" {
|
||||
body = validTodayGeneratedTextJSON()
|
||||
}
|
||||
if req.PromptID == "weather.tomorrow_generated_text" {
|
||||
body = validTomorrowGeneratedTextJSON()
|
||||
}
|
||||
|
||||
@@ -337,6 +337,80 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
||||
t.Fatalf("output = %#v, want today planning stanza", output)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) == 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||
len(planning.OutdoorPlanning) == 0 ||
|
||||
len(planning.LateDayChangeWatch) == 0 {
|
||||
t.Fatalf("today 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.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
||||
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
||||
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
||||
}
|
||||
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
||||
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal today planning: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
||||
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []report.ID{report.Tomorrow, report.DailyToday} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(id)
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
ctx.Derived.DailySummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) != 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||
len(planning.OutdoorPlanning) != 0 ||
|
||||
len(planning.LateDayChangeWatch) != 0 {
|
||||
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
@@ -430,6 +504,16 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
}
|
||||
}
|
||||
|
||||
func todayModuleContext() ModuleContext {
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx.Resolved.Definition = report.Definition{
|
||||
ID: report.Today,
|
||||
Name: "Today Report",
|
||||
PromptID: "weather.today_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{
|
||||
|
||||
@@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||
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}
|
||||
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.Tomorrow},
|
||||
SupportedReports: []report.ID{report.DailyToday, 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.Tomorrow, report.Hourly},
|
||||
SupportedReports: []report.ID{report.DailyToday, 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.Tomorrow},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
@@ -374,6 +374,15 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
},
|
||||
{
|
||||
ID: module.TodayPlanning,
|
||||
StanzaName: "today_planning",
|
||||
DefaultOptions: module.TodayPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Today},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTodayPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
|
||||
@@ -112,6 +112,43 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
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} {
|
||||
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`) {
|
||||
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{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.CurrentConditions},
|
||||
{ID: module.NarrativeForecast},
|
||||
{ID: module.HourlyForecast},
|
||||
{ID: module.DerivedDailySummary},
|
||||
{ID: module.DerivedDaypartSummaries},
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{ID: module.AreaForecastDiscussion},
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
{ID: module.OutdoorWindows},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
@@ -119,6 +156,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TodayPlanning,
|
||||
module.TomorrowPlanning,
|
||||
} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
|
||||
@@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
case report.DailyToday, report.Today:
|
||||
return "today"
|
||||
case report.Tomorrow:
|
||||
return "tomorrow"
|
||||
|
||||
@@ -28,6 +28,13 @@ type TomorrowPlanning struct {
|
||||
OvernightChangeWatch []string
|
||||
}
|
||||
|
||||
type TodayPlanning struct {
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OutdoorPlanning []string
|
||||
LateDayChangeWatch []string
|
||||
}
|
||||
|
||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
var best *OutdoorWindow
|
||||
var worst *OutdoorWindow
|
||||
@@ -48,6 +55,49 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
return OutdoorWindows{Best: best, Worst: worst}
|
||||
}
|
||||
|
||||
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
planning := &TodayPlanning{}
|
||||
morning := daypartNamed(summary.Dayparts, "morning")
|
||||
if morning != nil {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||
}
|
||||
if len(planning.MorningReadiness) == 0 {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||
}
|
||||
}
|
||||
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||
}
|
||||
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
||||
if len(planning.OutdoorPlanning) == 0 {
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||
}
|
||||
|
||||
for _, name := range []string{"afternoon", "evening"} {
|
||||
daypart := daypartNamed(summary.Dayparts, name)
|
||||
if daypart != nil {
|
||||
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||
}
|
||||
}
|
||||
if len(planning.LateDayChangeWatch) == 0 {
|
||||
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
||||
}
|
||||
|
||||
return planning
|
||||
}
|
||||
|
||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||
planning := &TomorrowPlanning{}
|
||||
morning := daypartNamed(summary.Dayparts, "morning")
|
||||
@@ -84,6 +134,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||
return planning
|
||||
}
|
||||
|
||||
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||
windows := buildOutdoorWindows(dayparts)
|
||||
var notes []string
|
||||
if windows.Best != nil {
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
}
|
||||
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
@@ -104,6 +166,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Late-day"
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
||||
}
|
||||
if len(daypart.AlertOverlaps) > 0 {
|
||||
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
|
||||
26
internal/briefing/today_planning_module.go
Normal file
26
internal/briefing/today_planning_module.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type TodayPlanningModule struct {
|
||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||
OutdoorPlanning []string `json:"outdoor_planning,omitempty"`
|
||||
LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"`
|
||||
}
|
||||
|
||||
func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil
|
||||
}
|
||||
planning := buildTodayPlanning(summary)
|
||||
value := TodayPlanningModule{}
|
||||
if planning != nil {
|
||||
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||
value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...)
|
||||
value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...)
|
||||
}
|
||||
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil
|
||||
}
|
||||
@@ -18,6 +18,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 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]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
@@ -216,7 +217,7 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
}
|
||||
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
case app.ReportDaily, app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
@@ -278,7 +279,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
addCommonFlags(fs, &opts.commonOptions, true)
|
||||
if report == app.ReportDaily {
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
if report == app.ReportStorm {
|
||||
|
||||
@@ -26,6 +26,9 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(output.stdout, "generate daily") {
|
||||
t.Fatalf("help output missing generate command:\n%s", output.stdout)
|
||||
}
|
||||
if !strings.Contains(output.stdout, "generate today") {
|
||||
t.Fatalf("help output missing today generate command:\n%s", output.stdout)
|
||||
}
|
||||
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
|
||||
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
|
||||
}
|
||||
@@ -196,7 +199,15 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
_ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
if len(dailyPackages) != 0 {
|
||||
t.Fatalf("daily packages = %#v, want morning batch to use Today only", dailyPackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
@@ -228,7 +239,7 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
||||
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
||||
}
|
||||
_ = oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
}
|
||||
|
||||
@@ -448,7 +459,7 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
@@ -469,16 +480,20 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
|
||||
todayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
t.Fatalf("glob today packages: %v", err)
|
||||
}
|
||||
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob 3-day packages: %v", err)
|
||||
}
|
||||
if len(dailyPackages) != 1 || len(threeDayPackages) != 1 {
|
||||
t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages)
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
if len(todayPackages) != 1 || len(threeDayPackages) != 1 || len(dailyPackages) != 0 {
|
||||
t.Fatalf("today packages = %#v, 3-day packages = %#v, daily packages = %#v; want Today and 3-day only", todayPackages, threeDayPackages, dailyPackages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +573,66 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "today.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"generate", "today",
|
||||
"--config", configPath,
|
||||
"--date", "2026-05-29",
|
||||
"--out", outPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# Today's Weather",
|
||||
"Today starts with showers before improving.",
|
||||
"Morning showers should taper as drier air arrives.",
|
||||
} {
|
||||
if !strings.Contains(string(report), want) {
|
||||
t.Fatalf("today report output missing %q:\n%s", want, string(report))
|
||||
}
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
dataPackage, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(dataPackage), "id: today") ||
|
||||
!strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") ||
|
||||
!strings.Contains(string(dataPackage), "today_planning:") {
|
||||
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
|
||||
}
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
if len(dailyPackages) != 0 {
|
||||
t.Fatalf("daily packages = %#v, want generate today to stay separate", dailyPackages)
|
||||
}
|
||||
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json")
|
||||
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json")
|
||||
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json")
|
||||
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "today", "*.md")
|
||||
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
|
||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
|
||||
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
|
||||
assertFileContains(t, managedReportPath, "# Today's Weather")
|
||||
}
|
||||
|
||||
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
@@ -615,6 +690,65 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||
}
|
||||
|
||||
func TestRunInspectTodayArtifacts(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"generate", "today",
|
||||
"--config", configPath,
|
||||
"--date", "2026-05-29",
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run(generate) error = %v", err)
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
|
||||
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run(inspect reports) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"reportId": "today"`) {
|
||||
t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
var sourcesOutput string
|
||||
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run(inspect %s) error = %v", command, err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") {
|
||||
t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String())
|
||||
}
|
||||
if command == "sources" {
|
||||
sourcesOutput = stdout.String()
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
|
||||
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
@@ -741,6 +875,7 @@ func TestResolveGenerateCommands(t *testing.T) {
|
||||
want app.ReportKind
|
||||
}{
|
||||
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||
{name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday},
|
||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
|
||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||
@@ -766,7 +901,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
|
||||
for _, name := range report.CommandNames() {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{name}
|
||||
if name == report.CommandNameDaily {
|
||||
if name == report.CommandNameDaily || name == report.CommandNameToday {
|
||||
args = append(args, "--date", "2026-05-29")
|
||||
}
|
||||
if name == report.CommandNameStorm {
|
||||
@@ -853,6 +988,39 @@ func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateTodayDate(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
defaultReq, err := runner.resolveGenerate([]string{"today"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGenerate(default) error = %v", err)
|
||||
}
|
||||
if defaultReq.Report != app.ReportToday {
|
||||
t.Fatalf("Report = %q, want today", defaultReq.Report)
|
||||
}
|
||||
if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
|
||||
t.Fatalf("default Date = %s, want 2026-05-29", got)
|
||||
}
|
||||
|
||||
explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGenerate(explicit) error = %v", err)
|
||||
}
|
||||
if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
|
||||
t.Fatalf("explicit Date = %s, want 2026-05-30", got)
|
||||
}
|
||||
resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
if resolved.Definition.ID != report.Today {
|
||||
t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID)
|
||||
}
|
||||
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" {
|
||||
t.Fatalf("valid period start = %s, want explicit UTC date", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
@@ -1107,6 +1275,20 @@ if [ "$1" = "run" ]; then
|
||||
fi
|
||||
shift
|
||||
done
|
||||
if [ "$prompt" = "weather.today_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"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."
|
||||
}
|
||||
JSON
|
||||
printf 'wrote generated text\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
@@ -1163,6 +1345,20 @@ 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.today_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"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."
|
||||
}
|
||||
JSON
|
||||
printf 'wrote generated text\n' >&2
|
||||
exit 0
|
||||
@@ -1216,13 +1412,30 @@ if [ "$1" = "render" ]; then
|
||||
fi
|
||||
if [ "$1" = "run" ]; then
|
||||
out=""
|
||||
prompt=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--out" ]; then
|
||||
shift
|
||||
out="$1"
|
||||
elif [ "$1" = "--prompt" ]; then
|
||||
shift
|
||||
prompt="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
if [ "$prompt" = "weather.today_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"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."
|
||||
}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -184,6 +184,47 @@ reports:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTodayReportModuleOverrides(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
reports:
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- today_planning
|
||||
`)
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
overrides, err := cfg.ReportModuleOverrides()
|
||||
if err != nil {
|
||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||
}
|
||||
items := overrides[report.Today]
|
||||
if len(items) != 5 {
|
||||
t.Fatalf("today override length = %d, want 5", len(items))
|
||||
}
|
||||
want := []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.TodayPlanning,
|
||||
}
|
||||
for i, id := range want {
|
||||
if items[i].ID != id {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
reports:
|
||||
@@ -286,6 +327,13 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||
},
|
||||
wantErr: "duplicates report override",
|
||||
},
|
||||
{
|
||||
name: "TodayAndDailyAreDistinct",
|
||||
reports: map[string]ReportConfig{
|
||||
"daily": {},
|
||||
"today": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UnknownReport",
|
||||
reports: map[string]ReportConfig{
|
||||
@@ -299,6 +347,12 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.Reports = tt.reports
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want report key error")
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.Hourly:
|
||||
case report.DailyToday, report.Tomorrow:
|
||||
case report.DailyToday, report.Today, report.Tomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
|
||||
const (
|
||||
schemaIDHourly = "hourly"
|
||||
schemaIDToday = "today"
|
||||
schemaIDTomorrow = "tomorrow"
|
||||
|
||||
templateIDHourly = "hourly"
|
||||
templateIDToday = "today"
|
||||
templateIDTomorrow = "tomorrow"
|
||||
)
|
||||
|
||||
@@ -44,6 +46,12 @@ var catalog = []catalogEntry{
|
||||
validate: validateHourly,
|
||||
renderContextBuilder: buildHourlyContext,
|
||||
},
|
||||
{
|
||||
schemaID: schemaIDToday,
|
||||
templateID: templateIDToday,
|
||||
validate: validateToday,
|
||||
renderContextBuilder: buildTodayContext,
|
||||
},
|
||||
{
|
||||
schemaID: schemaIDTomorrow,
|
||||
templateID: templateIDTomorrow,
|
||||
@@ -134,6 +142,10 @@ func validateHourly(data []byte) (any, []byte, error) {
|
||||
return ValidateHourly(data)
|
||||
}
|
||||
|
||||
func validateToday(data []byte) (any, []byte, error) {
|
||||
return ValidateToday(data)
|
||||
}
|
||||
|
||||
func validateTomorrow(data []byte) (any, []byte, error) {
|
||||
return ValidateTomorrow(data)
|
||||
}
|
||||
@@ -146,6 +158,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
|
||||
return BuildHourlyRenderContext(metadata, snapshot, hourly, 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 {
|
||||
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
|
||||
}
|
||||
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
|
||||
}
|
||||
|
||||
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||
tomorrow, ok := generated.(Tomorrow)
|
||||
if !ok {
|
||||
|
||||
@@ -93,6 +93,32 @@ func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
||||
definition := report.Definition{
|
||||
ID: report.Today,
|
||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||
GeneratedTextSchemaID: "today",
|
||||
TemplateID: "today",
|
||||
}
|
||||
handler, err := LookupDefinition(definition)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||
}
|
||||
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
|
||||
t.Fatalf("handler IDs = %q/%q, want today/today", 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 Today template source", template)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||
if err != nil {
|
||||
@@ -129,6 +155,29 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
|
||||
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||
}
|
||||
|
||||
todayHandler, err := LookupDefinition(report.Definition{
|
||||
ID: report.Today,
|
||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||
GeneratedTextSchemaID: "today",
|
||||
TemplateID: "today",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||
}
|
||||
today, normalized, err := todayHandler.Validate([]byte(`{
|
||||
"summary": " Showers are likely today. ",
|
||||
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(today) error = %v", err)
|
||||
}
|
||||
if _, ok := today.(Today); !ok {
|
||||
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||
@@ -161,4 +210,24 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
||||
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
|
||||
}
|
||||
|
||||
todayHandler, err := LookupDefinition(report.Definition{
|
||||
ID: report.Today,
|
||||
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||
GeneratedTextSchemaID: "today",
|
||||
TemplateID: "today",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||
}
|
||||
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
|
||||
Summary: "Storms become more likely tomorrow.",
|
||||
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requires today generated text`) {
|
||||
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,46 @@ type TomorrowRenderContext struct {
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
|
||||
type TodayRenderContext struct {
|
||||
Report TodayReportContext
|
||||
GeneratedText Today
|
||||
Modules TodayTemplateModules
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
|
||||
type TodayReportContext struct {
|
||||
Title string
|
||||
ForecastDate time.Time
|
||||
ForecastDateLabel string
|
||||
ForecastDayName string
|
||||
GeneratedAt time.Time
|
||||
GeneratedAtLabel string
|
||||
ValidPeriod timeutil.Period
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type TodayTemplateModules struct {
|
||||
Metadata *briefing.MetadataModule
|
||||
CurrentConditions *briefing.CurrentConditionsModule
|
||||
HourlyForecast *briefing.HourlyForecastModule
|
||||
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||
Dayparts []TodayDaypartContext
|
||||
PrecipTiming *briefing.PrecipTimingModule
|
||||
AlertDigest *briefing.AlertDigestModule
|
||||
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||
WeatherStory *briefing.WeatherStoryModule
|
||||
TodayPlanning *briefing.TodayPlanningModule
|
||||
}
|
||||
|
||||
type TodayDaypartContext struct {
|
||||
Key string
|
||||
Summary briefing.DerivedDaypartSummaryModule
|
||||
}
|
||||
|
||||
type TomorrowReportContext struct {
|
||||
Title string
|
||||
ForecastDate time.Time
|
||||
@@ -116,6 +156,40 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
|
||||
}, 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 {
|
||||
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err)
|
||||
}
|
||||
if metadata.GeneratedAt.IsZero() {
|
||||
return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required")
|
||||
}
|
||||
if !metadata.ValidPeriod.IsValid() {
|
||||
return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required")
|
||||
}
|
||||
modules, err := todayTemplateModules(snapshot, derived)
|
||||
if err != nil {
|
||||
return TodayRenderContext{}, err
|
||||
}
|
||||
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||
return TodayRenderContext{
|
||||
Report: TodayReportContext{
|
||||
Title: "Today's Weather",
|
||||
ForecastDate: forecastDate,
|
||||
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
|
||||
ForecastDayName: forecastDate.Format("Monday"),
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Timezone: metadata.Timezone,
|
||||
},
|
||||
GeneratedText: generated,
|
||||
Modules: modules,
|
||||
Collected: collected,
|
||||
Derived: derived,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||
if err != nil {
|
||||
@@ -151,6 +225,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
|
||||
}, nil
|
||||
}
|
||||
|
||||
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
|
||||
lookup := newModuleSnapshotLookup(snapshot)
|
||||
metadata, err := lookup.metadata()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
current, err := lookup.currentConditions()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
hourly, err := lookup.hourlyForecast()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
daily, err := lookup.derivedDailySummary()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
dayparts, err := lookup.derivedDaypartSummaries()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
precip, err := lookup.precipTiming()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
alerts, err := lookup.alertDigest()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
outlooks, err := lookup.spcConvectiveOutlooks()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
discussion, err := lookup.areaForecastDiscussion()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
spcDiscussion, err := lookup.spcConvectiveDiscussion()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
story, err := lookup.weatherStory()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
planning, err := lookup.todayPlanning()
|
||||
if err != nil {
|
||||
return TodayTemplateModules{}, err
|
||||
}
|
||||
return TodayTemplateModules{
|
||||
Metadata: metadata,
|
||||
CurrentConditions: current,
|
||||
HourlyForecast: hourly,
|
||||
DerivedDailySummary: daily,
|
||||
DerivedDaypartSummaries: dayparts,
|
||||
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries),
|
||||
PrecipTiming: precip,
|
||||
AlertDigest: alerts,
|
||||
SPCConvectiveOutlooks: outlooks,
|
||||
AreaForecastDiscussion: discussion,
|
||||
SPCConvectiveDiscussion: spcDiscussion,
|
||||
WeatherStory: story,
|
||||
TodayPlanning: planning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||
lookup := newModuleSnapshotLookup(snapshot)
|
||||
metadata, err := lookup.metadata()
|
||||
@@ -329,6 +470,10 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
|
||||
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
||||
}
|
||||
|
||||
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
|
||||
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
|
||||
}
|
||||
|
||||
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
|
||||
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
|
||||
}
|
||||
@@ -346,11 +491,34 @@ func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
|
||||
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||
out := make([]TodayDaypartContext, 0, len(orderedRows))
|
||||
for _, row := range orderedRows {
|
||||
out = append(out, TodayDaypartContext{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))
|
||||
for _, row := range orderedRows {
|
||||
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type orderedDaypartRow struct {
|
||||
Key string
|
||||
Summary briefing.DerivedDaypartSummaryModule
|
||||
}
|
||||
|
||||
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
|
||||
if dayparts == nil || len(*dayparts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TomorrowDaypartContext, 0, len(*dayparts))
|
||||
out := make([]orderedDaypartRow, 0, len(*dayparts))
|
||||
seen := map[string]struct{}{}
|
||||
for _, daypart := range ordered {
|
||||
for _, key := range daypartModuleKeyCandidates(daypart) {
|
||||
@@ -362,7 +530,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, TomorrowDaypartContext{Key: key, Summary: value})
|
||||
out = append(out, orderedDaypartRow{Key: key, Summary: value})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -375,7 +543,7 @@ func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummary
|
||||
}
|
||||
sort.Strings(remaining)
|
||||
for _, key := range remaining {
|
||||
out = append(out, TomorrowDaypartContext{Key: key, Summary: (*dayparts)[key]})
|
||||
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -136,6 +136,130 @@ func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTodayRenderContext(t *testing.T) {
|
||||
metadata := testTodayMetadata()
|
||||
snapshot := testTodaySnapshot(t)
|
||||
generated := Today{
|
||||
Summary: "Today 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 := testTodayDerived()
|
||||
|
||||
ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, collected, derived)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTodayRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Report.Title != "Today's Weather" {
|
||||
t.Fatalf("Report.Title = %q, want Today'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 != "Monday, June 15, 2026 at 7:14 AM" {
|
||||
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||
}
|
||||
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Today {
|
||||
t.Fatalf("Modules.Metadata = %#v, want today metadata", ctx.Modules.Metadata)
|
||||
}
|
||||
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
|
||||
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
|
||||
}
|
||||
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
|
||||
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
|
||||
}
|
||||
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
|
||||
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
|
||||
}
|
||||
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
||||
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||
}
|
||||
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||
}
|
||||
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TodayPlanning == nil {
|
||||
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
|
||||
}
|
||||
if ctx.Modules.TodayPlanning.MorningReadiness[0] != "Take sunglasses early." {
|
||||
t.Fatalf("Modules.TodayPlanning = %#v, want today planning facts", ctx.Modules.TodayPlanning)
|
||||
}
|
||||
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("today", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Today's Weather",
|
||||
"**Forecast date:** Monday, June 15, 2026",
|
||||
"Today starts quiet, then showers become more likely later in the day.",
|
||||
"Currently, it is 58°F and clear.",
|
||||
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
|
||||
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
|
||||
"## 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.",
|
||||
"## Planning Notes",
|
||||
"- Take sunglasses early.",
|
||||
"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{
|
||||
"# Today's Weather",
|
||||
"## Current Conditions",
|
||||
"## Daypart Forecast",
|
||||
"- **Morning:**",
|
||||
"- **Afternoon:**",
|
||||
"## Precipitation Timing",
|
||||
"## Planning Notes",
|
||||
"## Forecast Discussion",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{
|
||||
Summary: "Dry weather is expected today.",
|
||||
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
|
||||
}, testCollected(), facts.DerivedFacts{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTodayRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 || ctx.Modules.TodayPlanning != nil {
|
||||
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
|
||||
}
|
||||
rendered, err := reporttemplate.Render("today", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, unwanted := range []string{"## Current Conditions", "## Precipitation Timing", "## Planning Notes"} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("rendered template included %q without module data:\n%s", unwanted, 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)
|
||||
@@ -290,6 +414,26 @@ func testTomorrowMetadata() briefing.Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
func testTodayMetadata() briefing.Metadata {
|
||||
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
|
||||
return briefing.Metadata{
|
||||
RunID: "run-today",
|
||||
ReportID: report.Today,
|
||||
PromptID: "weather.today_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Location: &briefing.LocationContext{
|
||||
Name: "Brentwood",
|
||||
Region: "MO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testCollected() facts.CollectedFacts {
|
||||
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
||||
}
|
||||
@@ -299,6 +443,14 @@ func testDerived() facts.DerivedFacts {
|
||||
}
|
||||
|
||||
func testTomorrowDerived() facts.DerivedFacts {
|
||||
return testCivilDayDerived()
|
||||
}
|
||||
|
||||
func testTodayDerived() facts.DerivedFacts {
|
||||
return testCivilDayDerived()
|
||||
}
|
||||
|
||||
func testCivilDayDerived() facts.DerivedFacts {
|
||||
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
|
||||
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
||||
return facts.DerivedFacts{
|
||||
@@ -321,6 +473,134 @@ func testTomorrowDerived() facts.DerivedFacts {
|
||||
}
|
||||
}
|
||||
|
||||
func testTodaySnapshot(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-today",
|
||||
ReportID: report.Today,
|
||||
PromptID: "weather.today_generated_text",
|
||||
GeneratedAt: testTodayMetadata().GeneratedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: testTodayMetadata().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,
|
||||
},
|
||||
"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.TodayPlanning,
|
||||
StanzaName: string(module.TodayPlanning),
|
||||
Value: briefing.TodayPlanningModule{
|
||||
MorningReadiness: []string{"Take sunglasses early."},
|
||||
OutdoorPlanning: []string{"Best outdoor window: Morning (quiet weather)."},
|
||||
},
|
||||
},
|
||||
})
|
||||
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{
|
||||
|
||||
37
internal/generatedtext/today.go
Normal file
37
internal/generatedtext/today.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Today struct {
|
||||
Summary string `json:"summary"`
|
||||
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
func ValidateToday(data []byte) (Today, []byte, error) {
|
||||
value, err := decodeGeneratedText[Today](data, "today")
|
||||
if err != nil {
|
||||
return Today{}, 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 Today{}, nil, fmt.Errorf("today generated text summary is required")
|
||||
}
|
||||
if len(value.ForecastDiscussion) == 0 {
|
||||
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
|
||||
}
|
||||
|
||||
normalized, err := normalizeGeneratedText(value, "today")
|
||||
if err != nil {
|
||||
return Today{}, nil, err
|
||||
}
|
||||
return value, normalized, nil
|
||||
}
|
||||
111
internal/generatedtext/today_test.go
Normal file
111
internal/generatedtext/today_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateTodayNormalizesJSON(t *testing.T) {
|
||||
value, normalized, err := ValidateToday([]byte(`{
|
||||
"summary": " Showers are likely today. ",
|
||||
"forecast_discussion": [
|
||||
" A front will keep rain chances elevated. ",
|
||||
"",
|
||||
" Temperatures stay mild through the afternoon. "
|
||||
],
|
||||
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||
"confidence": " Medium "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToday() error = %v", err)
|
||||
}
|
||||
if value.Summary != "Showers are likely today." {
|
||||
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||
}
|
||||
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the 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 likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the 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 TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
|
||||
_, normalized, err := ValidateToday([]byte(`{
|
||||
"summary": "Showers are likely today.",
|
||||
"forecast_discussion": ["A front will keep rain chances elevated."],
|
||||
"precipitation_timing": " ",
|
||||
"confidence": " "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToday() error = %v", err)
|
||||
}
|
||||
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
|
||||
if string(normalized) != want {
|
||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "malformed",
|
||||
in: `{`,
|
||||
want: "decode today generated text",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
|
||||
want: `unknown field "extra"`,
|
||||
},
|
||||
{
|
||||
name: "missing summary",
|
||||
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||
want: "summary is required",
|
||||
},
|
||||
{
|
||||
name: "blank summary",
|
||||
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||
want: "summary is required",
|
||||
},
|
||||
{
|
||||
name: "missing forecast discussion",
|
||||
in: `{"summary":"Showers are likely today."}`,
|
||||
want: "forecast discussion is required",
|
||||
},
|
||||
{
|
||||
name: "blank forecast discussion",
|
||||
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
|
||||
want: "forecast discussion is required",
|
||||
},
|
||||
{
|
||||
name: "forecast discussion wrong type",
|
||||
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
|
||||
want: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, err := ValidateToday([]byte(test.in))
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToday() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||
OutdoorWindows ID = "outdoor_windows"
|
||||
TodayPlanning ID = "today_planning"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
)
|
||||
|
||||
@@ -148,4 +149,5 @@ type WeatherStoryOptions struct{}
|
||||
type SPCConvectiveOutlooksOptions struct{}
|
||||
type SPCConvectiveDiscussionOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TodayPlanningOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
|
||||
@@ -40,6 +40,7 @@ var briefingStanzaCategories = map[string]string{
|
||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||
string(module.TodayPlanning): categoryDerivedSummaries,
|
||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||
|
||||
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
req.Modules = snapshotWithOutputs(t,
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||
module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}},
|
||||
)
|
||||
|
||||
pkg, err := Build(req)
|
||||
@@ -120,6 +121,9 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||
}
|
||||
if _, ok := pkg.Briefing.Values["today_planning"]; !ok {
|
||||
t.Fatal("Briefing.Values[today_planning] missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ type ID string
|
||||
|
||||
const (
|
||||
DailyToday ID = "daily_today"
|
||||
Today ID = "today"
|
||||
Tomorrow ID = "tomorrow"
|
||||
Hourly ID = "hourly"
|
||||
ThreeDay ID = "three_day"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
CommandNameDaily = "daily"
|
||||
CommandNameToday = "today"
|
||||
CommandNameTomorrow = "tomorrow"
|
||||
CommandNameHourly = "hourly"
|
||||
CommandNameThreeDay = "three-day"
|
||||
@@ -21,6 +22,8 @@ func IDForCommandName(name string) (ID, error) {
|
||||
switch name {
|
||||
case CommandNameDaily:
|
||||
return DailyToday, nil
|
||||
case CommandNameToday:
|
||||
return Today, nil
|
||||
case CommandNameTomorrow:
|
||||
return Tomorrow, nil
|
||||
case CommandNameHourly:
|
||||
@@ -39,6 +42,7 @@ func IDForCommandName(name string) (ID, error) {
|
||||
func CommandNames() []string {
|
||||
return []string{
|
||||
CommandNameDaily,
|
||||
CommandNameToday,
|
||||
CommandNameTomorrow,
|
||||
CommandNameHourly,
|
||||
CommandNameThreeDay,
|
||||
@@ -52,6 +56,8 @@ func IDForConfigKey(key string) (ID, error) {
|
||||
switch normalized {
|
||||
case "daily", "daily_today":
|
||||
return DailyToday, nil
|
||||
case "today":
|
||||
return Today, nil
|
||||
case "tomorrow":
|
||||
return Tomorrow, nil
|
||||
case "hourly":
|
||||
|
||||
@@ -26,7 +26,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
|
||||
}
|
||||
switch batch {
|
||||
case Morning:
|
||||
ids := []ID{DailyToday, ThreeDay}
|
||||
ids := []ID{Today, ThreeDay}
|
||||
if req.Now.In(req.Location).Weekday() != time.Sunday {
|
||||
ids = append(ids, Weekend)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,41 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||
}
|
||||
|
||||
func TestTodayValidPeriod(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||
|
||||
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
|
||||
if resolved.Definition.PromptID != "weather.today_generated_text" {
|
||||
t.Fatalf("PromptID = %q, want weather.today_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 != "today" {
|
||||
t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID)
|
||||
}
|
||||
if resolved.Definition.GeneratedTextSchemaID != "today" {
|
||||
t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||
date := mustParse("2026-05-31T12:00:00-05:00")
|
||||
|
||||
resolved, err := Resolve(Today, 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")
|
||||
}
|
||||
|
||||
func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T20:00:00-05:00")
|
||||
@@ -201,8 +236,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "daily_today,three_day" {
|
||||
t.Fatalf("ids = %v, want daily_today and three_day", ids)
|
||||
if strings.Join(ids, ",") != "today,three_day" {
|
||||
t.Fatalf("ids = %v, want today and three_day", ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +281,7 @@ func TestIDForCommandName(t *testing.T) {
|
||||
want ID
|
||||
}{
|
||||
{name: "daily", want: DailyToday},
|
||||
{name: "today", want: Today},
|
||||
{name: "tomorrow", want: Tomorrow},
|
||||
{name: "hourly", want: Hourly},
|
||||
{name: "three-day", want: ThreeDay},
|
||||
@@ -263,9 +299,20 @@ func TestIDForCommandName(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
if names := strings.Join(CommandNames(), ","); names != "daily,tomorrow,hourly,three-day,weekend,storm" {
|
||||
if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" {
|
||||
t.Fatalf("CommandNames() = %s, want stable command names", names)
|
||||
}
|
||||
dailyID, err := IDForCommandName("daily")
|
||||
if err != nil {
|
||||
t.Fatalf("IDForCommandName(daily) error = %v", err)
|
||||
}
|
||||
todayID, err := IDForCommandName("today")
|
||||
if err != nil {
|
||||
t.Fatalf("IDForCommandName(today) error = %v", err)
|
||||
}
|
||||
if dailyID == todayID {
|
||||
t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID)
|
||||
}
|
||||
if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) {
|
||||
t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err)
|
||||
}
|
||||
@@ -278,6 +325,7 @@ func TestIDForConfigKey(t *testing.T) {
|
||||
}{
|
||||
{key: "daily", want: DailyToday},
|
||||
{key: "daily_today", want: DailyToday},
|
||||
{key: "today", want: Today},
|
||||
{key: "tomorrow", want: Tomorrow},
|
||||
{key: "hourly", want: Hourly},
|
||||
{key: "three_day", want: ThreeDay},
|
||||
@@ -303,6 +351,17 @@ 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)
|
||||
}
|
||||
dailyID, err := IDForConfigKey("daily")
|
||||
if err != nil {
|
||||
t.Fatalf("IDForConfigKey(daily) error = %v", err)
|
||||
}
|
||||
todayID, err := IDForConfigKey("today")
|
||||
if err != nil {
|
||||
t.Fatalf("IDForConfigKey(today) error = %v", err)
|
||||
}
|
||||
if dailyID == todayID {
|
||||
t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchForCommandName(t *testing.T) {
|
||||
@@ -342,7 +401,7 @@ func TestMorningBatchReportOrder(t *testing.T) {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "daily_today,three_day,weekend" {
|
||||
if strings.Join(ids, ",") != "today,three_day,weekend" {
|
||||
t.Fatalf("ids = %v, want morning report order", ids)
|
||||
}
|
||||
}
|
||||
@@ -359,7 +418,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||
|
||||
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
||||
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||
want := []string{"daily_today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
||||
want := []string{"daily_today", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||
}
|
||||
@@ -384,7 +443,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
|
||||
if !definition.Generated {
|
||||
continue
|
||||
}
|
||||
if definition.ID == Hourly || definition.ID == Tomorrow {
|
||||
if definition.ID == Hourly || 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)
|
||||
@@ -426,6 +485,14 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
compatiblePriorIDs: []ID{DailyToday},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Today,
|
||||
artifactGroup: "today",
|
||||
batchOutputName: "today.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Today},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Tomorrow,
|
||||
artifactGroup: "tomorrow",
|
||||
@@ -522,6 +589,25 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Today,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
module.TodayPlanning,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Tomorrow,
|
||||
want: []module.ID{
|
||||
|
||||
@@ -13,6 +13,7 @@ type Registry struct {
|
||||
func DefaultRegistry() Registry {
|
||||
definitions := []Definition{
|
||||
dailyTodayDefinition(),
|
||||
todayDefinition(),
|
||||
tomorrowDefinition(),
|
||||
hourlyDefinition(),
|
||||
threeDayDefinition(),
|
||||
@@ -74,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
||||
}
|
||||
|
||||
func (r Registry) All() []Definition {
|
||||
ids := []ID{DailyToday, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||
ids := []ID{DailyToday, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||
out := make([]Definition, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if definition, ok := r.definitions[id]; ok {
|
||||
|
||||
51
internal/report/today_report.go
Normal file
51
internal/report/today_report.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func todayDefinition() Definition {
|
||||
return Definition{
|
||||
ID: Today,
|
||||
Name: "Today Report",
|
||||
PromptID: "weather.today_generated_text",
|
||||
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||
TemplateID: "today",
|
||||
GeneratedTextSchemaID: "today",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "today",
|
||||
BatchOutputName: "today.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Today},
|
||||
Modules: todayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveToday,
|
||||
}
|
||||
}
|
||||
|
||||
func todayModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
module.TodayPlanning,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveToday(req ResolveRequest) (timeutil.Period, error) {
|
||||
if !req.Date.IsZero() {
|
||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||
}
|
||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||
}
|
||||
38
internal/reporttemplate/prompts/today.generated_text.md
Normal file
38
internal/reporttemplate/prompts/today.generated_text.md
Normal file
@@ -0,0 +1,38 @@
|
||||
TASK: You are writing structured prose slots for a current-day 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 today's valid period in `report.valid_period` for the configured location.
|
||||
|
||||
Return these fields:
|
||||
|
||||
- `summary`: required. 1-2 sentences summarizing the main weather story for today.
|
||||
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to today.
|
||||
- `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 today. 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 current 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 `today_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.
|
||||
@@ -13,11 +13,13 @@ var assets embed.FS
|
||||
|
||||
var templates = map[string]string{
|
||||
"hourly": "templates/hourly.md.tmpl",
|
||||
"today": "templates/today.md.tmpl",
|
||||
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||
}
|
||||
|
||||
var schemas = map[string]string{
|
||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||
"today": "schemas/today.generated_text.schema.json",
|
||||
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ func TestTomorrowTemplateLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayTemplateLookup(t *testing.T) {
|
||||
source, err := Template("today")
|
||||
if err != nil {
|
||||
t.Fatalf("Template() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", "## Daypart Forecast", "## Planning Notes", "## Forecast Discussion"} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Fatalf("template missing %q:\n%s", want, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaLookup(t *testing.T) {
|
||||
data, err := Schema("hourly")
|
||||
if err != nil {
|
||||
@@ -69,6 +81,37 @@ func TestTomorrowSchemaLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodaySchemaLookup(t *testing.T) {
|
||||
data, err := Schema("today")
|
||||
if err != nil {
|
||||
t.Fatalf("Schema() error = %v", err)
|
||||
}
|
||||
schema := assertSchema(t, data, "summary,forecast_discussion")
|
||||
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("schema property forecast_discussion missing or invalid")
|
||||
}
|
||||
if property["type"] != "array" {
|
||||
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
|
||||
}
|
||||
if property["minItems"] != float64(1) {
|
||||
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
|
||||
}
|
||||
items, ok := property["items"].(map[string]any)
|
||||
if !ok || items["type"] != "string" {
|
||||
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
|
||||
}
|
||||
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
|
||||
property, ok := schema.Properties[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema property %q missing or invalid", field)
|
||||
}
|
||||
if property["type"] != "string" {
|
||||
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
|
||||
t.Helper()
|
||||
schema := assertSchema(t, data, required)
|
||||
@@ -305,6 +348,99 @@ func TestRenderTomorrow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderToday(t *testing.T) {
|
||||
rendered, err := Render("today", testTodayRenderContext{
|
||||
Report: testTodayReportContext{
|
||||
Title: "Today's Weather",
|
||||
ForecastDateLabel: "Monday, June 15, 2026",
|
||||
GeneratedAtLabel: "Monday, June 15, 2026 at 7:14 AM",
|
||||
},
|
||||
GeneratedText: testTomorrowGeneratedText{
|
||||
Summary: "Today 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: testTodayModules{
|
||||
CurrentConditions: &testCurrentConditions{
|
||||
ConditionTextLower: "clear",
|
||||
TemperatureF: intPtr(58),
|
||||
},
|
||||
Dayparts: []testTomorrowDaypart{
|
||||
{
|
||||
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"},
|
||||
},
|
||||
},
|
||||
TodayPlanning: &testTodayPlanning{
|
||||
MorningReadiness: []string{"Morning weather looks routine."},
|
||||
LateDayChangeWatch: []string{"Watch late-day shower timing."},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Today's Weather",
|
||||
"**Forecast date:** Monday, June 15, 2026",
|
||||
"**Updated:** Monday, June 15, 2026 at 7:14 AM",
|
||||
"Today starts dry before showers return later in the day.",
|
||||
"Currently, it is 58°F and clear.",
|
||||
"- **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.",
|
||||
"## Planning Notes",
|
||||
"- Morning weather looks routine.",
|
||||
"- Watch late-day shower timing.",
|
||||
"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{
|
||||
"# Today's Weather",
|
||||
"## Current Conditions",
|
||||
"## Daypart Forecast",
|
||||
"- **Morning:**",
|
||||
"- **Afternoon:**",
|
||||
"## Precipitation Timing",
|
||||
"## Planning Notes",
|
||||
"## Forecast Discussion",
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
||||
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||
Report: testTomorrowReportContext{
|
||||
@@ -415,6 +551,12 @@ type testTomorrowRenderContext struct {
|
||||
Modules testTomorrowModules
|
||||
}
|
||||
|
||||
type testTodayRenderContext struct {
|
||||
Report testTodayReportContext
|
||||
GeneratedText testTomorrowGeneratedText
|
||||
Modules testTodayModules
|
||||
}
|
||||
|
||||
type testReportContext struct {
|
||||
Title string
|
||||
LocationName string
|
||||
@@ -435,6 +577,12 @@ type testTomorrowReportContext struct {
|
||||
GeneratedAtLabel string
|
||||
}
|
||||
|
||||
type testTodayReportContext struct {
|
||||
Title string
|
||||
ForecastDateLabel string
|
||||
GeneratedAtLabel string
|
||||
}
|
||||
|
||||
type testTomorrowGeneratedText struct {
|
||||
Summary string
|
||||
ForecastDiscussion []string
|
||||
@@ -458,6 +606,20 @@ type testTomorrowModules struct {
|
||||
PrecipTiming *testPrecipTiming
|
||||
}
|
||||
|
||||
type testTodayModules struct {
|
||||
CurrentConditions *testCurrentConditions
|
||||
Dayparts []testTomorrowDaypart
|
||||
PrecipTiming *testPrecipTiming
|
||||
TodayPlanning *testTodayPlanning
|
||||
}
|
||||
|
||||
type testTodayPlanning struct {
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OutdoorPlanning []string
|
||||
LateDayChangeWatch []string
|
||||
}
|
||||
|
||||
type testTomorrowDaypart struct {
|
||||
Key string
|
||||
Summary testDaypartSummary
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "weatherreporter.today.generated_text.schema.json",
|
||||
"title": "Today 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
internal/reporttemplate/templates/today.md.tmpl
Normal file
33
internal/reporttemplate/templates/today.md.tmpl
Normal file
@@ -0,0 +1,33 @@
|
||||
# {{ .Report.Title }}
|
||||
|
||||
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
|
||||
{{ with .Modules.CurrentConditions }}{{ $current := . }}## Current Conditions
|
||||
Currently, {{ with .TemperatureF }}it is {{ . }}°F{{ with $current.ConditionTextLower }} and {{ . }}{{ end }}{{ else }}{{ with .ConditionTextLower }}conditions are {{ . }}{{ else }}latest current conditions are available{{ end }}{{ end }}.
|
||||
|
||||
{{ end }}## 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 }}{{ with .Modules.TodayPlanning }}{{ if or .MorningReadiness .CommuteSchoolWorkdayConcerns .OutdoorPlanning .LateDayChangeWatch }}
|
||||
## Planning Notes
|
||||
{{ with .MorningReadiness }}{{ range . }}- {{ . }}
|
||||
{{ end }}{{ end }}{{ with .CommuteSchoolWorkdayConcerns }}{{ range . }}- {{ . }}
|
||||
{{ end }}{{ end }}{{ with .OutdoorPlanning }}{{ range . }}- {{ . }}
|
||||
{{ end }}{{ end }}{{ with .LateDayChangeWatch }}{{ range . }}- {{ . }}
|
||||
{{ end }}{{ end }}{{ end }}{{ end }}
|
||||
## Forecast Discussion
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
@@ -63,6 +63,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
||||
validDate: "2026-05-30",
|
||||
runID: "20260529T230000.000000000Z_tomorrow",
|
||||
},
|
||||
{
|
||||
name: "today",
|
||||
resolved: resolveTodayAt(t, "2026-05-29T05:00:00-05:00"),
|
||||
group: "today",
|
||||
validDate: "2026-05-29",
|
||||
runID: "20260529T100000.000000000Z_today",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -403,6 +410,24 @@ func TestFindPriorSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotSupportsToday(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveTodayAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveTodayAt(t, "2026-05-29T08:00:00-05:00")
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior == nil {
|
||||
t.Fatal("FindPriorSnapshot() = nil, want prior Today snapshot")
|
||||
}
|
||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||
@@ -532,6 +557,26 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveTodayAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
now, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Today, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
|
||||
Reference in New Issue
Block a user