Compare commits
8 Commits
d0d0b698f9
...
fe9c40741a
| Author | SHA1 | Date | |
|---|---|---|---|
| fe9c40741a | |||
| 88004a1827 | |||
| a515b7e7d9 | |||
| 696454cf34 | |||
| 8c97788682 | |||
| 5203440ba0 | |||
| 3d452a120a | |||
| 4eece7cc8a |
26
docs/cli.md
26
docs/cli.md
@@ -20,7 +20,7 @@ Markdown report after final metadata is saved.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
@@ -40,17 +40,15 @@ weatherreporter inspect sources [--config PATH] RUN_ID
|
|||||||
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
||||||
preflight artifact, managed Markdown report, and metadata under the configured
|
preflight artifact, managed Markdown report, and metadata under the configured
|
||||||
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||||
notification uses the managed report path, not the extra copy. `generate today`,
|
notification uses the managed report path, not the extra copy. `generate daily`,
|
||||||
`generate tomorrow`, and `generate hourly` write managed generated-text artifacts,
|
`generate today`, `generate tomorrow`, and `generate hourly` write managed
|
||||||
validate structured text from Scriptorium, and render the managed Markdown
|
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||||
report from embedded templates. `generate hourly` covers the next six hours in
|
the managed Markdown report from embedded templates. `generate daily` requires
|
||||||
the effective report timezone and does not accept date or event window flags.
|
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
|
||||||
`generate storm` requires explicit event-window bounds with `--start` and
|
command error and stops before weather data is fetched. `generate hourly`
|
||||||
`--end`.
|
covers the next six hours in the effective report timezone and does not accept
|
||||||
|
date or event window flags. `generate storm` requires explicit event-window
|
||||||
`generate daily` remains the existing Daily Today report. It is separate from
|
bounds with `--start` and `--end`.
|
||||||
`generate today`; the commands use different report IDs, artifact groups,
|
|
||||||
prompts, and config override keys.
|
|
||||||
|
|
||||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
@@ -76,7 +74,7 @@ They do not fetch weather data or invoke `scriptorium`.
|
|||||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||||
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
|
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
|
||||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||||
- `--date YYYY-MM-DD`: optional date for `generate daily` and `generate today`; defaults to the current local date in the configured timezone.
|
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||||
@@ -110,7 +108,7 @@ weatherreporter inspect modules 20260529T100000.000000000Z_today
|
|||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and source
|
`inspect reports` lists recent generated runs with artifact paths and source
|
||||||
|
|||||||
@@ -183,12 +183,12 @@ report definitions. Omit a report entry to use its default module order.
|
|||||||
|
|
||||||
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
|
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
|
||||||
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
|
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
|
||||||
are also valid, including `daily_today`, `three_day_outlook`,
|
are also valid, including `three_day_outlook`, `weekend_outlook`, and
|
||||||
`weekend_outlook`, and `storm_report`. Hyphens and underscores are treated
|
`storm_report`. Hyphens and underscores are treated equivalently in report
|
||||||
equivalently in report keys.
|
keys. Retired report keys are not supported.
|
||||||
|
|
||||||
`reports.today` applies only to the Today Report. `reports.daily` and
|
`reports.today` applies only to the Today Report. `reports.daily` applies only
|
||||||
`reports.daily_today` apply only to the existing Daily Today report.
|
to the dated Daily Report.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
@@ -211,6 +211,7 @@ reports:
|
|||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
- spc_convective_discussion
|
- spc_convective_discussion
|
||||||
|
- daily_planning
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
today:
|
today:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
|
|||||||
@@ -135,14 +135,16 @@ failure context.
|
|||||||
## Batch Workflow
|
## Batch Workflow
|
||||||
|
|
||||||
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
||||||
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
|
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
|
||||||
from report definitions. Batch generation continues independent reports after a
|
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
|
||||||
failure, records each result, writes compact status lines to stderr, emits a
|
batches. Batch output copy names come from report definitions. Batch generation
|
||||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
continues independent reports after a failure, records each result, writes
|
||||||
When notification is enabled, each successfully generated report is notified
|
compact status lines to stderr, emits a JSON summary to stdout, and returns an
|
||||||
independently. Notification failure marks that report failed, records
|
aggregate error when any report failed. When notification is enabled, each
|
||||||
notification fields in the batch result, and does not stop later reports.
|
successfully generated report is notified independently. Notification failure
|
||||||
`--out-dir` copies are never used as notification source files.
|
marks that report failed, records notification fields in the batch result, and
|
||||||
|
does not stop later reports. `--out-dir` copies are never used as notification
|
||||||
|
source files.
|
||||||
|
|
||||||
## Inspection Workflow
|
## Inspection Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ Outputs:
|
|||||||
`weather_story`
|
`weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, `today_planning`, and `tomorrow_planning`
|
`outdoor_windows`, `today_planning`, `tomorrow_planning`, and
|
||||||
|
`daily_planning`
|
||||||
|
|
||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
|
||||||
|
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
|
||||||
|
package and embedded Markdown template.
|
||||||
|
|
||||||
Tomorrow Report supports the Daily-style civil-day modules plus
|
Tomorrow Report supports the Daily-style civil-day modules plus
|
||||||
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
||||||
GeneratedText prompt package and embedded Markdown template.
|
GeneratedText prompt package and embedded Markdown template.
|
||||||
@@ -50,13 +55,18 @@ package and embedded Markdown template.
|
|||||||
morning readiness, commute/school/workday concerns, outdoor planning, and
|
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||||
late-day change-watch fields. It is compatible with `report.Today` only.
|
late-day change-watch fields. It is compatible with `report.Today` only.
|
||||||
|
|
||||||
|
`daily_planning` is a dated Daily deterministic planning stanza with morning
|
||||||
|
readiness, commute/school/workday concerns, and overnight change-watch fields.
|
||||||
|
It is compatible only with the `daily` report ID value. The default Daily
|
||||||
|
Report composition includes it.
|
||||||
|
|
||||||
Hourly Report supports source and valid-period modules that operate over its
|
Hourly Report supports source and valid-period modules that operate over its
|
||||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||||
It does not support daily/daypart-only modules such as
|
It does not support daily/daypart-only modules such as
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
||||||
`today_planning`, or `tomorrow_planning`.
|
`today_planning`, `tomorrow_planning`, or `daily_planning`.
|
||||||
|
|
||||||
Prompt-facing module values use local, human-readable date and time labels
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
|
|||||||
@@ -9,30 +9,45 @@ This document describes structured generated-text handling in
|
|||||||
generated-text-template reports and builds curated render contexts for
|
generated-text-template reports and builds curated render contexts for
|
||||||
templates. It also owns the generated-text catalog that connects report
|
templates. It also owns the generated-text catalog that connects report
|
||||||
definitions to validators, render-context builders, schema assets, and template
|
definitions to validators, render-context builders, schema assets, and template
|
||||||
assets. The implemented contracts are Today Report, Tomorrow Report, and Hourly
|
assets. The implemented contracts are Daily render context, Today Report,
|
||||||
Report.
|
Tomorrow Report, and Hourly Report.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- raw GeneratedText JSON for Today, Tomorrow Report, or Hourly Report
|
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
|
||||||
- report metadata from `internal/briefing`
|
- report metadata from `internal/briefing`
|
||||||
- a module snapshot from `internal/module`
|
- a module snapshot from `internal/module`
|
||||||
- validated generated text
|
- validated generated text
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Daily` generated text
|
||||||
- typed `Today` generated text
|
- typed `Today` generated text
|
||||||
- typed `Tomorrow` generated text
|
- typed `Tomorrow` generated text
|
||||||
- typed `Hourly` generated text
|
- typed `Hourly` generated text
|
||||||
- normalized stable JSON for validated generated text
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `DailyRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `TodayRenderContext` values for `internal/reporttemplate`
|
- typed `TodayRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
- generated-text catalog handlers for report definitions that use
|
- generated-text catalog handlers for report definitions that use
|
||||||
`generated_text_template`
|
`generated_text_template`
|
||||||
|
|
||||||
|
The Daily generated text JSON accepts the same public fields and validation
|
||||||
|
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
|
||||||
|
template ID `daily`, with prompt ID `weather.daily_generated_text`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The hourly generated text JSON accepts:
|
The hourly generated text JSON accepts:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -96,8 +111,8 @@ JSON when blank.
|
|||||||
- Malformed generated-text JSON fails with decode context.
|
- Malformed generated-text JSON fails with decode context.
|
||||||
- Unknown generated-text JSON fields fail during decoding.
|
- Unknown generated-text JSON fields fail during decoding.
|
||||||
- Empty required fields fail after trimming whitespace.
|
- Empty required fields fail after trimming whitespace.
|
||||||
- Today and Tomorrow forecast discussion fails when no nonblank paragraphs
|
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
|
||||||
remain.
|
paragraphs remain.
|
||||||
- Missing optional render-context stanzas become nil module pointers.
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
- Invalid render metadata, including missing timezone, missing generated time,
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
or invalid valid period, fails before template rendering.
|
or invalid valid period, fails before template rendering.
|
||||||
@@ -109,6 +124,7 @@ JSON when blank.
|
|||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/generatedtext/hourly_test.go`
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/daily_test.go`
|
||||||
- `internal/generatedtext/today_test.go`
|
- `internal/generatedtext/today_test.go`
|
||||||
- `internal/generatedtext/tomorrow_test.go`
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
- `internal/generatedtext/catalog_test.go`
|
- `internal/generatedtext/catalog_test.go`
|
||||||
|
|||||||
@@ -44,10 +44,33 @@ The registry recognizes these IDs:
|
|||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `today_planning`
|
- `today_planning`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
|
- `daily_planning`
|
||||||
|
|
||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## Daily Composition
|
||||||
|
|
||||||
|
The default Daily Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `daily_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Daily template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
## Today Composition
|
## Today Composition
|
||||||
|
|
||||||
The default Today Report module order is:
|
The default Today Report module order is:
|
||||||
@@ -92,6 +115,22 @@ The default Tomorrow Report module order is:
|
|||||||
The embedded Tomorrow template uses selected deterministic fields from these
|
The embedded Tomorrow template uses selected deterministic fields from these
|
||||||
module outputs after GeneratedText validation.
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Daily Planning
|
||||||
|
|
||||||
|
`daily_planning` emits dated daily planning facts for the `daily` report ID.
|
||||||
|
Its output stanza is also named `daily_planning`. The module is supported only
|
||||||
|
by that report ID and depends on daily summaries for the selected local civil
|
||||||
|
day. The default Daily Report composition includes it.
|
||||||
|
|
||||||
|
The output uses this shape:
|
||||||
|
|
||||||
|
- `morning_readiness`
|
||||||
|
- `commute_school_workday_concerns`
|
||||||
|
- `overnight_change_watch`
|
||||||
|
|
||||||
|
The type is `briefing.DailyPlanningModule`; it is independent from
|
||||||
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|
||||||
## Today Planning
|
## Today Planning
|
||||||
|
|
||||||
`today_planning` emits current-day planning facts for Today Report. Its output
|
`today_planning` emits current-day planning facts for Today Report. Its output
|
||||||
@@ -99,13 +138,12 @@ stanza is also named `today_planning`. The module is supported only by Today
|
|||||||
Report and depends on daily and daypart summaries for the current local civil
|
Report and depends on daily and daypart summaries for the current local civil
|
||||||
day.
|
day.
|
||||||
|
|
||||||
The output uses the same shape as Tomorrow planning:
|
The output uses this shape:
|
||||||
|
|
||||||
- `morning_readiness`
|
- `morning_readiness`
|
||||||
- `commute`
|
- `commute_school_workday_concerns`
|
||||||
- `outdoor_planning`
|
- `outdoor_planning`
|
||||||
- `things_to_watch`
|
- `late_day_change_watch`
|
||||||
- `confidence`
|
|
||||||
|
|
||||||
The type is `briefing.TodayPlanningModule`; it is independent from
|
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||||
`briefing.TomorrowPlanningModule`.
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
|
|||||||
labels; canonical report metadata and source timestamps remain structured
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
timestamps where applicable.
|
timestamps where applicable.
|
||||||
|
|
||||||
Today Report, Tomorrow Report, and Hourly Report module snapshots use the same
|
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
|
||||||
package schema and categories when converted into prompt input. The default
|
use the same package schema and categories when converted into prompt input.
|
||||||
hourly module list places
|
The default hourly module list places
|
||||||
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
||||||
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
||||||
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
||||||
@@ -78,10 +78,11 @@ include civil-day summary stanzas. Generated-text and render context artifacts
|
|||||||
are produced later in app orchestration and are not part of the YAML data
|
are produced later in app orchestration and are not part of the YAML data
|
||||||
package.
|
package.
|
||||||
|
|
||||||
The default Today and Tomorrow module lists include civil-day summary stanzas,
|
The default Daily, Today, and Tomorrow module lists include civil-day summary
|
||||||
planning stanzas, and `hourly_forecast` in the data package before structured
|
stanzas, planning stanzas, and `hourly_forecast` in the data package before
|
||||||
GeneratedText is requested from Scriptorium. Today uses `today_planning`;
|
structured GeneratedText is requested from Scriptorium. Daily uses
|
||||||
Tomorrow uses `tomorrow_planning`.
|
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
|
||||||
|
`tomorrow_planning`.
|
||||||
|
|
||||||
Current categories are:
|
Current categories are:
|
||||||
|
|
||||||
|
|||||||
@@ -29,25 +29,25 @@ Each report definition declares:
|
|||||||
|
|
||||||
Report-owned helpers map public command names and config keys to report IDs.
|
Report-owned helpers map public command names and config keys to report IDs.
|
||||||
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
|
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
|
||||||
`three-day`, `weekend`, and `storm`. Config keys also accept underscore and
|
`three-day`, `weekend`, and `storm`. Config keys also accept selected
|
||||||
legacy descriptive aliases such as `daily_today`, `three_day_outlook`,
|
underscore and descriptive aliases such as `three_day_outlook`,
|
||||||
`weekend_outlook`, and `storm_report`.
|
`weekend_outlook`, and `storm_report`.
|
||||||
|
|
||||||
`daily` resolves to the existing Daily Today report ID `daily_today`.
|
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
|
||||||
`today` resolves to the independent Today report ID `today`. `reports.today`
|
independent Today report ID `today`. `reports.today` is not an alias for
|
||||||
is not an alias for `reports.daily` or `reports.daily_today`.
|
`reports.daily`, and retired report keys are not supported.
|
||||||
|
|
||||||
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||||
Their template and structured-text schema identifiers are empty. Today Report,
|
Their template and structured-text schema identifiers are empty. Daily Report,
|
||||||
Tomorrow Report, and Hourly Report declare `generated_text_template`; the app
|
Today Report, Tomorrow Report, and Hourly Report declare
|
||||||
uses their template and schema identifiers to validate generated text and render
|
`generated_text_template`; the app uses their template and schema identifiers
|
||||||
embedded Markdown templates.
|
to validate generated text and render embedded Markdown templates.
|
||||||
|
|
||||||
## Reports
|
## Reports
|
||||||
|
|
||||||
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
|
| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
|
||||||
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
|
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
|
||||||
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||||
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
@@ -59,8 +59,8 @@ All report definitions are eligible for generation.
|
|||||||
|
|
||||||
## Valid Periods
|
## Valid Periods
|
||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Report covers the selected local civil day and requires an explicit
|
||||||
day when no date override is supplied.
|
date.
|
||||||
- Today Report covers the selected local civil day, or the current local civil
|
- Today Report covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Tomorrow Report covers the next local civil day from generation time.
|
- Tomorrow Report covers the next local civil day from generation time.
|
||||||
@@ -91,14 +91,13 @@ report override keys.
|
|||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions. Report
|
output path copying uses batch output names from report definitions. Report
|
||||||
module overrides can use short keys such as `today`, `tomorrow`, and `hourly`,
|
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
|
||||||
canonical report IDs such as `daily_today`, or descriptive names such as
|
`hourly`, or descriptive names such as `three_day_outlook`.
|
||||||
`three_day_outlook`.
|
|
||||||
|
|
||||||
## Batch Membership
|
## Batch Membership
|
||||||
|
|
||||||
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
|
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
|
||||||
except on Sunday. Evening batches include Tomorrow Report. Daily Today and
|
except on Sunday. Evening batches include Tomorrow Report. Daily Report and
|
||||||
Hourly Report are not part of a scheduled batch.
|
Hourly Report are not part of a scheduled batch.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ in `internal/reporttemplate`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/reporttemplate` owns repository-native report templates and companion
|
`internal/reporttemplate` owns repository-native report templates and companion
|
||||||
GeneratedText JSON schemas. The implemented template contracts are Today
|
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
|
||||||
Report, Tomorrow Report, and Hourly Report.
|
Tomorrow, and Hourly.
|
||||||
|
|
||||||
The package embeds assets from:
|
The package embeds assets from:
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ Outputs:
|
|||||||
- GeneratedText schema bytes for prompt/schema configuration
|
- GeneratedText schema bytes for prompt/schema configuration
|
||||||
- rendered Markdown bytes for app orchestration to persist
|
- rendered Markdown bytes for app orchestration to persist
|
||||||
|
|
||||||
The implemented template IDs are `today`, `tomorrow`, and `hourly`. The
|
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
|
||||||
implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by
|
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
|
||||||
matching `*.generated_text.schema.json` files.
|
`hourly`, backed by matching `*.generated_text.schema.json` files.
|
||||||
|
|
||||||
The Today generated-text prompt source is
|
Generated-text prompt sources are maintained under
|
||||||
`internal/reporttemplate/prompts/today.generated_text.md`, selected by prompt
|
`internal/reporttemplate/prompts/`, including Daily's
|
||||||
ID `weather.today_generated_text`.
|
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ render-context builder, and embedded assets.
|
|||||||
|
|
||||||
## Template Contracts
|
## Template Contracts
|
||||||
|
|
||||||
Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
||||||
|
|
||||||
- report metadata labels such as title, location, valid period, and generation
|
- report metadata labels such as title, location, valid period, and generation
|
||||||
time
|
time
|
||||||
@@ -62,10 +62,13 @@ Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
|||||||
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
||||||
forecast discussion, SPC discussion, and weather story
|
forecast discussion, SPC discussion, and weather story
|
||||||
|
|
||||||
Today and Tomorrow additionally expose forecast-date labels, ordered daypart
|
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
|
||||||
forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph
|
daypart forecast rows, daily/daypart summaries, planning facts, and a
|
||||||
forecast discussion generated-text slot. The ordered daypart slice is built in
|
multi-paragraph forecast discussion generated-text slot. The ordered daypart
|
||||||
Go so templates do not range over maps.
|
slice is built in Go so templates do not range over maps.
|
||||||
|
|
||||||
|
The Daily template asset uses the same Markdown structure as Tomorrow's
|
||||||
|
template and renders from `generatedtext.DailyRenderContext`.
|
||||||
|
|
||||||
Templates use `text/template` with `missingkey=error`, so missing context fields
|
Templates use `text/template` with `missingkey=error`, so missing context fields
|
||||||
fail rendering instead of producing incomplete Markdown.
|
fail rendering instead of producing incomplete Markdown.
|
||||||
@@ -78,10 +81,11 @@ to write for each generated-text prompt. Hourly requires:
|
|||||||
- `summary`
|
- `summary`
|
||||||
- `forecast_discussion`
|
- `forecast_discussion`
|
||||||
|
|
||||||
Today and Tomorrow require `summary` and a nonempty `forecast_discussion`
|
Daily, Today, and Tomorrow require `summary` and a nonempty
|
||||||
array. All generated-text schemas allow optional `precipitation_timing` and
|
`forecast_discussion` array. All generated-text schemas allow optional
|
||||||
`confidence`, and reject additional properties. Weather truth remains in module
|
`precipitation_timing` and `confidence`, and reject additional properties.
|
||||||
outputs; GeneratedText is limited to prose slots consumed by the template.
|
Weather truth remains in module outputs; GeneratedText is limited to prose
|
||||||
|
slots consumed by the template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
|
|||||||
selects the latest earlier snapshot whose report ID is compatible with the
|
selects the latest earlier snapshot whose report ID is compatible with the
|
||||||
current report definition.
|
current report definition.
|
||||||
|
|
||||||
- Daily Today compares with prior Daily Today snapshots for the same valid
|
- Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
local date.
|
local date.
|
||||||
- Today Report compares with prior Today Report snapshots for the same valid
|
- Today Report compares with prior Today Report snapshots for the same valid
|
||||||
local date.
|
local date.
|
||||||
|
|||||||
@@ -24,16 +24,15 @@ a JSON module snapshot, build a YAML prompt input data package, run
|
|||||||
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||||
managed Markdown report path.
|
managed Markdown report path.
|
||||||
|
|
||||||
`generate today`, `generate tomorrow`, and `generate hourly` use the
|
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
|
||||||
generated-text-template workflow. They run structured `scriptorium run` to raw
|
use the generated-text-template workflow. They run structured `scriptorium run`
|
||||||
GeneratedText JSON, validate the structured text, save a render context, and
|
to raw GeneratedText JSON, validate the structured text, save a render context,
|
||||||
render the managed Markdown report from embedded templates. `generate today`
|
and render the managed Markdown report from embedded templates. `generate
|
||||||
covers the selected or current local civil day. `generate hourly` covers the
|
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
|
||||||
six-hour rolling period from generation time in the effective report timezone
|
`generate today` covers the selected or current local civil day. `generate
|
||||||
and is not part of scheduled morning or evening batches.
|
hourly` covers the six-hour rolling period from generation time in the
|
||||||
|
effective report timezone and is not part of scheduled morning or evening
|
||||||
`generate daily` remains the existing Daily Today report and is independent of
|
batches.
|
||||||
`generate today`.
|
|
||||||
|
|
||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
Markdown report after report rendering succeeds and final metadata is saved.
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
@@ -69,6 +68,10 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
|
<run_id>.generated_text.raw.json
|
||||||
|
<run_id>.generated_text.run.json
|
||||||
|
<run_id>.generated_text.json
|
||||||
|
<run_id>.render_context.json
|
||||||
today/
|
today/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -196,7 +199,7 @@ period do not overwrite each other.
|
|||||||
RunIDs are based on generation time plus report ID:
|
RunIDs are based on generation time plus report ID:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20260529T100000.123456789Z_daily_today
|
20260529T100000.123456789Z_daily
|
||||||
20260529T100000.123456789Z_today
|
20260529T100000.123456789Z_today
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -239,6 +242,8 @@ The default idempotency key appends RunID to the rendered bundle ID so each
|
|||||||
report generation has a distinct retry identity. The default bundle path uses
|
report generation has a distinct retry identity. The default bundle path uses
|
||||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||||
|
For Daily, the default report ID and artifact group values are both `daily`,
|
||||||
|
and the default output filename value is `daily.md`.
|
||||||
For Today, the default report ID and artifact group values are both `today`,
|
For Today, the default report ID and artifact group values are both `today`,
|
||||||
and the batch output filename value is `today.md`.
|
and the batch output filename value is `today.md`.
|
||||||
|
|
||||||
@@ -293,13 +298,13 @@ source provenance and warnings without dumping full weather payloads.
|
|||||||
Recent Changes are computed from structured module snapshots, not rendered
|
Recent Changes are computed from structured module snapshots, not rendered
|
||||||
Markdown or YAML text.
|
Markdown or YAML text.
|
||||||
|
|
||||||
Daily Today compares with prior Daily Today snapshots for the same valid local
|
Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
date. Today Report compares with prior Today Report snapshots for the same
|
local date. Today Report compares with prior Today Report snapshots for the
|
||||||
valid local date. Tomorrow Report compares with prior Tomorrow Report snapshots
|
same valid local date. Tomorrow Report compares with prior Tomorrow Report
|
||||||
for the same valid local date. 3-Day Outlook compares with prior compatible
|
snapshots for the same valid local date. 3-Day Outlook compares with prior
|
||||||
3-Day snapshots for the same valid local date. Weekend Outlook compares with
|
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
|
||||||
prior compatible Weekend snapshots for the same weekend window. Hourly Report
|
compares with prior compatible Weekend snapshots for the same weekend window.
|
||||||
and Storm Report leave Recent Changes empty.
|
Hourly Report and Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
@@ -312,7 +317,7 @@ A failed generation run may still leave useful artifacts:
|
|||||||
preflight JSON and metadata are written for inspection.
|
preflight JSON and metadata are written for inspection.
|
||||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||||
and metadata remain available.
|
and metadata remain available.
|
||||||
- Generated-text failures for Today, Tomorrow, and Hourly reports preserve
|
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
|
||||||
available intermediate artifacts, such as the structured run result, raw
|
available intermediate artifacts, such as the structured run result, raw
|
||||||
generated-text JSON, validated generated text, and render context. Metadata
|
generated-text JSON, validated generated text, and render context. Metadata
|
||||||
links those paths when it can be safely written.
|
links those paths when it can be safely written.
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ especially generated-text-template reports.
|
|||||||
|
|
||||||
Templates are Go `text/template` files. The current implemented templates are:
|
Templates are Go `text/template` files. The current implemented templates are:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/daily.md.tmpl`
|
||||||
- `internal/reporttemplate/templates/today.md.tmpl`
|
- `internal/reporttemplate/templates/today.md.tmpl`
|
||||||
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
Templates are rendered from structured contexts such as `TodayRenderContext`,
|
Templates are rendered from structured contexts such as `DailyRenderContext`,
|
||||||
`TomorrowRenderContext`, and `HourlyRenderContext`. Weather data collection,
|
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
|
||||||
derivation, module execution, generated text validation, and artifact paths are
|
Weather data collection, derivation, module execution, generated text
|
||||||
handled before template rendering.
|
validation, and artifact paths are handled before template rendering.
|
||||||
|
|
||||||
## Editing Rules
|
## Editing Rules
|
||||||
|
|
||||||
@@ -115,6 +116,37 @@ Prefer `.Modules.Dayparts` over ranging through
|
|||||||
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
falls back to sorted keys for any unmatched entries.
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Daily Context
|
||||||
|
|
||||||
|
The Daily template receives the same five top-level values as Tomorrow, using
|
||||||
|
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
|
||||||
|
|
||||||
|
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Daily generated text uses `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
|
||||||
|
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
|
||||||
|
should be rendered with `range`.
|
||||||
|
|
||||||
|
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
|
||||||
|
source `internal/reporttemplate/prompts/daily.generated_text.md`.
|
||||||
|
|
||||||
|
Daily modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
## Today Context
|
## Today Context
|
||||||
|
|
||||||
The Today template receives the same five top-level values as Tomorrow, using
|
The Today template receives the same five top-level values as Tomorrow, using
|
||||||
@@ -328,6 +360,6 @@ go run ./cmd/weatherreporter --help
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Template render tests currently exercise the hourly template through
|
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
|
||||||
`internal/generatedtext/render_context_test.go` and
|
templates through `internal/generatedtext/render_context_test.go` and
|
||||||
`internal/reporttemplate/reporttemplate_test.go`.
|
`internal/reporttemplate/reporttemplate_test.go`.
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ Relevant docs: [Operations guide](operations.md),
|
|||||||
|
|
||||||
## Generated Text Validation Fails
|
## Generated Text Validation Fails
|
||||||
|
|
||||||
Symptom: Today, Tomorrow, or Hourly generation fails with generated-text decode,
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
|
||||||
unknown-field, required-field, or multiple-JSON-values context.
|
decode, unknown-field, required-field, or multiple-JSON-values context.
|
||||||
|
|
||||||
Likely cause: Scriptorium wrote structured JSON that does not match the
|
Likely cause: Scriptorium wrote structured JSON that does not match the
|
||||||
GeneratedText contract for the selected report.
|
GeneratedText contract for the selected report.
|
||||||
@@ -210,8 +210,8 @@ Relevant docs: [Operations guide](operations.md),
|
|||||||
|
|
||||||
## Template Rendering Fails
|
## Template Rendering Fails
|
||||||
|
|
||||||
Symptom: Today, Tomorrow, or Hourly generation fails with report template parsing or
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
|
||||||
execution context after generated text validation succeeds.
|
parsing or execution context after generated text validation succeeds.
|
||||||
|
|
||||||
Likely cause: an embedded template references a missing context field or
|
Likely cause: an embedded template references a missing context field or
|
||||||
receives a value shape that does not match its typed render context.
|
receives a value shape that does not match its typed render context.
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ reports:
|
|||||||
- spc_convective_discussion
|
- spc_convective_discussion
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
|
- daily_planning
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
today:
|
today:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,7 +30,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
"render",
|
"render",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -84,7 +84,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
"run",
|
"run",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--out", "/tmp/daily.md",
|
"--out", "/tmp/daily.md",
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1107,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||||
}
|
}
|
||||||
switch reportID {
|
switch reportID {
|
||||||
case report.DailyToday, report.Today, report.Tomorrow:
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay:
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
return changes.CompareThreeDay(previous, current, thresholds)
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
ExitCode: 0,
|
ExitCode: 0,
|
||||||
OutputPath: "",
|
OutputPath: "",
|
||||||
},
|
},
|
||||||
|
structuredRunResult: &scriptorium.StructuredRunResult{
|
||||||
|
Command: []string{"scriptorium", "run"},
|
||||||
|
Stderr: "wrote generated text",
|
||||||
|
ExitCode: 0,
|
||||||
|
},
|
||||||
runBody: "# Daily Report\n\nRain this morning.\n",
|
runBody: "# Daily Report\n\nRain this morning.\n",
|
||||||
}
|
}
|
||||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
@@ -128,25 +133,28 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if renderer.renderCalls != 1 {
|
if renderer.renderCalls != 1 {
|
||||||
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
|
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
|
||||||
}
|
}
|
||||||
if renderer.runCalls != 1 {
|
if renderer.structuredRunCalls != 1 {
|
||||||
t.Fatalf("run calls = %d, want 1", renderer.runCalls)
|
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
|
||||||
}
|
}
|
||||||
if renderer.renderRequest.PromptID != "weather.daily_report" {
|
if renderer.runCalls != 0 {
|
||||||
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID)
|
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
|
||||||
|
}
|
||||||
|
if renderer.renderRequest.PromptID != "weather.daily_generated_text" {
|
||||||
|
t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID)
|
||||||
}
|
}
|
||||||
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
||||||
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
||||||
}
|
}
|
||||||
if renderer.runRequest.DataPackagePath != result.DataPackagePath {
|
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
|
||||||
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath)
|
t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
|
||||||
}
|
}
|
||||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
|
||||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
t.Fatalf("structured run OutputPath = %q, want raw generated text path %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
|
||||||
}
|
}
|
||||||
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
||||||
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
||||||
}
|
}
|
||||||
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||||
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read module snapshot: %v", err)
|
t.Fatalf("read module snapshot: %v", err)
|
||||||
@@ -252,7 +260,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report output: %v", err)
|
t.Fatalf("read report output: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Friday's Weather") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,8 +762,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
|
|||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportDaily,
|
Report: ReportThreeDay,
|
||||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
@@ -764,7 +771,7 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
|
|||||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: successfulRenderer("# Daily Report\n"),
|
Renderer: successfulRenderer("# 3-Day Outlook\n"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
@@ -1125,7 +1132,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
|
|||||||
if req.PipelineID != "weatherreporter.daily" {
|
if req.PipelineID != "weatherreporter.daily" {
|
||||||
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
|
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
|
||||||
}
|
}
|
||||||
if req.BundleID != "weatherreporter.home.daily_today" {
|
if req.BundleID != "weatherreporter.home.daily" {
|
||||||
t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
|
t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
|
||||||
}
|
}
|
||||||
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
|
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
|
||||||
@@ -1208,10 +1215,9 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Run",
|
name: "Run",
|
||||||
renderer: &recordingRenderer{
|
renderer: &recordingRenderer{
|
||||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"},
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"},
|
||||||
runErr: errors.New("run failed"),
|
structuredRunErr: errors.New("run failed"),
|
||||||
runBody: "# Daily Report\n",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1350,12 +1356,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
runResult: &scriptorium.RunResult{
|
structuredRunResult: &scriptorium.StructuredRunResult{
|
||||||
Stderr: "validation failed",
|
Stderr: "validation failed",
|
||||||
ExitCode: 2,
|
ExitCode: 2,
|
||||||
},
|
},
|
||||||
runErr: errors.New("scriptorium run exited with code 2: validation failed"),
|
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
|
||||||
runBody: "# Daily Report\n",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||||
@@ -1366,8 +1371,8 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("GenerateReport() error = nil, want run error")
|
t.Fatal("GenerateReport() error = nil, want run error")
|
||||||
}
|
}
|
||||||
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
|
||||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
|
||||||
}
|
}
|
||||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1380,8 +1385,11 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
if _, err := os.Stat(paths.Metadata); err != nil {
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
|
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(paths.RenderedReport); err != nil {
|
if _, err := os.Stat(paths.GeneratedTextRaw); err != nil {
|
||||||
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err)
|
t.Fatalf("expected raw generated text from failed run %q: %v", paths.GeneratedTextRaw, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.RenderedReport); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("rendered report exists after failed generated-text run: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2053,6 +2061,22 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
|
||||||
|
_, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
}, mustParse("2026-05-29T08:00:00-05:00"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ResolveGenerate() error = nil, want required date error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requires an explicit date") {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %q, want required date context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
|
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
if err := os.WriteFile(path, []byte(`
|
if err := os.WriteFile(path, []byte(`
|
||||||
@@ -2552,6 +2576,10 @@ func validTodayGeneratedTextJSON() string {
|
|||||||
return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validDailyGeneratedTextJSON() string {
|
||||||
|
return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
}
|
||||||
|
|
||||||
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
@@ -2835,9 +2863,10 @@ func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metada
|
|||||||
|
|
||||||
func successfulRenderer(body string) *recordingRenderer {
|
func successfulRenderer(body string) *recordingRenderer {
|
||||||
return &recordingRenderer{
|
return &recordingRenderer{
|
||||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||||
runBody: body,
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||||
|
runBody: body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2913,6 +2942,9 @@ func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.Str
|
|||||||
if req.PromptID == "weather.tomorrow_generated_text" {
|
if req.PromptID == "weather.tomorrow_generated_text" {
|
||||||
body = validTomorrowGeneratedTextJSON()
|
body = validTomorrowGeneratedTextJSON()
|
||||||
}
|
}
|
||||||
|
if req.PromptID == "weather.daily_generated_text" {
|
||||||
|
body = validDailyGeneratedTextJSON()
|
||||||
|
}
|
||||||
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2942,8 +2974,12 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
|
|||||||
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
|
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
|
||||||
r.structuredRunCalls++
|
r.structuredRunCalls++
|
||||||
r.structuredRunRequest = req
|
r.structuredRunRequest = req
|
||||||
if r.structuredRunBody != "" {
|
body := r.structuredRunBody
|
||||||
if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil {
|
if body == "" {
|
||||||
|
body = validGeneratedTextJSONForPrompt(req.PromptID)
|
||||||
|
}
|
||||||
|
if body != "" {
|
||||||
|
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2952,3 +2988,18 @@ func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.Str
|
|||||||
}
|
}
|
||||||
return r.structuredRunResult, r.structuredRunErr
|
return r.structuredRunResult, r.structuredRunErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validGeneratedTextJSONForPrompt(promptID string) string {
|
||||||
|
switch promptID {
|
||||||
|
case "weather.daily_generated_text":
|
||||||
|
return validDailyGeneratedTextJSON()
|
||||||
|
case "weather.today_generated_text":
|
||||||
|
return validTodayGeneratedTextJSON()
|
||||||
|
case "weather.tomorrow_generated_text":
|
||||||
|
return validTomorrowGeneratedTextJSON()
|
||||||
|
case "weather.hourly_generated_text":
|
||||||
|
return validHourlyGeneratedTextJSON()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[MetadataModule](t, output)
|
value := moduleValue[MetadataModule](t, output)
|
||||||
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("metadata = %#v, want report identity", value)
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
}
|
}
|
||||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
@@ -395,7 +395,7 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
|||||||
|
|
||||||
func testModuleContext() ModuleContext {
|
func testModuleContext() ModuleContext {
|
||||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
resolved := report.Resolved{
|
resolved := report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
|
|||||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type DailyPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
value := DailyPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,7 +154,7 @@ func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T
|
|||||||
|
|
||||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,6 +337,77 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
||||||
|
t.Fatalf("output = %#v, want daily planning stanza", output)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) == 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) == 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
||||||
|
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
||||||
|
}
|
||||||
|
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
||||||
|
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal daily planning: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
||||||
|
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(id)
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) != 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) != 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := todayModuleContext()
|
ctx := todayModuleContext()
|
||||||
@@ -382,7 +453,7 @@ func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
for _, id := range []report.ID{report.Tomorrow, report.DailyToday} {
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
t.Run(string(id), func(t *testing.T) {
|
t.Run(string(id), func(t *testing.T) {
|
||||||
ctx := derivedModuleContext(id)
|
ctx := derivedModuleContext(id)
|
||||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
@@ -413,7 +484,7 @@ func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
|||||||
|
|
||||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries = nil
|
ctx.Derived.DailySummaries = nil
|
||||||
ctx.Derived.DaypartSummaries = nil
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
@@ -505,7 +576,7 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func todayModuleContext() ModuleContext {
|
func todayModuleContext() ModuleContext {
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Resolved.Definition = report.Definition{
|
ctx.Resolved.Definition = report.Definition{
|
||||||
ID: report.Today,
|
ID: report.Today,
|
||||||
Name: "Today Report",
|
Name: "Today Report",
|
||||||
@@ -514,6 +585,16 @@ func todayModuleContext() ModuleContext {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dailyModuleContext() ModuleContext {
|
||||||
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
ctx.Resolved.Definition = report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
|||||||
@@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
@@ -276,7 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
},
|
},
|
||||||
@@ -295,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
},
|
},
|
||||||
@@ -392,5 +392,14 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.DailyPlanning,
|
||||||
|
StanzaName: "daily_planning",
|
||||||
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Daily},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildDailyPlanningModule,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestHourlyDefaultModuleOptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||||
t.Fatalf("error = %v, want unknown module", err)
|
t.Fatalf("error = %v, want unknown module", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
})
|
})
|
||||||
@@ -106,8 +106,8 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||||
t.Fatalf("error = %v, want incompatible report", err)
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
|||||||
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||||
t.Fatalf("ValidateComposition(today) error = %v", err)
|
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||||
}
|
}
|
||||||
for _, id := range []report.ID{report.Tomorrow, report.DailyToday} {
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
t.Run(string(id), func(t *testing.T) {
|
t.Run(string(id), func(t *testing.T) {
|
||||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
@@ -127,6 +127,21 @@ func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||||
@@ -158,6 +173,7 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
|||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TodayPlanning,
|
module.TodayPlanning,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
module.DailyPlanning,
|
||||||
} {
|
} {
|
||||||
t.Run(string(id), func(t *testing.T) {
|
t.Run(string(id), func(t *testing.T) {
|
||||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||||
@@ -188,7 +204,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||||
@@ -198,7 +214,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday, report.Today:
|
case report.Daily, report.Today:
|
||||||
return "today"
|
return "today"
|
||||||
case report.Tomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ type TomorrowPlanning struct {
|
|||||||
OvernightChangeWatch []string
|
OvernightChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type morningCommuteOvernightPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OvernightChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
type TodayPlanning struct {
|
type TodayPlanning struct {
|
||||||
MorningReadiness []string
|
MorningReadiness []string
|
||||||
CommuteSchoolWorkdayConcerns []string
|
CommuteSchoolWorkdayConcerns []string
|
||||||
@@ -99,7 +105,16 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||||
planning := &TomorrowPlanning{}
|
base := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
return &TomorrowPlanning{
|
||||||
|
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
||||||
|
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
||||||
|
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
||||||
|
planning := &morningCommuteOvernightPlanning{}
|
||||||
morning := daypartNamed(summary.Dayparts, "morning")
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
if morning != nil {
|
if morning != nil {
|
||||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
@@ -217,7 +217,15 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch reportKind {
|
switch reportKind {
|
||||||
case app.ReportDaily, app.ReportToday:
|
case app.ReportDaily:
|
||||||
|
if opts.Date == "" {
|
||||||
|
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, err
|
||||||
|
}
|
||||||
|
case app.ReportToday:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestRunHelpLongFlag(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(output.stdout, "generate daily") {
|
if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") {
|
||||||
t.Fatalf("help output missing generate command:\n%s", output.stdout)
|
t.Fatalf("help output missing generate command:\n%s", output.stdout)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output.stdout, "generate today") {
|
if !strings.Contains(output.stdout, "generate today") {
|
||||||
@@ -251,7 +251,7 @@ func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
|||||||
Failed: 1,
|
Failed: 1,
|
||||||
Reports: []app.BatchReportResult{
|
Reports: []app.BatchReportResult{
|
||||||
{
|
{
|
||||||
ReportID: "daily_today",
|
ReportID: "daily",
|
||||||
Status: "succeeded",
|
Status: "succeeded",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
NotificationStatus: "accepted",
|
NotificationStatus: "accepted",
|
||||||
@@ -299,11 +299,11 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
|||||||
Failed: 1,
|
Failed: 1,
|
||||||
Reports: []app.BatchReportResult{
|
Reports: []app.BatchReportResult{
|
||||||
{
|
{
|
||||||
ReportID: "daily_today",
|
ReportID: "daily",
|
||||||
Status: "failed",
|
Status: "failed",
|
||||||
Error: "notify report daily_today: upload failed: [redacted]",
|
Error: "notify report daily: upload failed: [redacted]",
|
||||||
NotificationStatus: "failed",
|
NotificationStatus: "failed",
|
||||||
NotificationError: "notify report daily_today: upload failed: [redacted]",
|
NotificationError: "notify report daily: upload failed: [redacted]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -526,7 +526,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Friday's Weather") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||||
@@ -540,7 +540,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") {
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily") {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "location:") ||
|
if !strings.Contains(string(data), "location:") ||
|
||||||
@@ -571,6 +571,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if len(reportMatches) != 1 {
|
if len(reportMatches) != 1 {
|
||||||
t.Fatalf("managed reports = %#v, want one", reportMatches)
|
t.Fatalf("managed reports = %#v, want one", reportMatches)
|
||||||
}
|
}
|
||||||
|
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json")
|
||||||
|
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json")
|
||||||
|
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json")
|
||||||
|
metadataPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json")
|
||||||
|
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
|
||||||
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
|
||||||
|
assertFileContains(t, metadataPath, `"generatedTextSchemaId": "daily"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||||
@@ -975,14 +983,44 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
req, err := runner.resolveGenerate([]string{"daily"})
|
req, err := runner.resolveGenerate([]string{"daily"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveGenerate() error = nil, want required date error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "generate daily requires --date YYYY-MM-DD") {
|
||||||
|
t.Fatalf("resolveGenerate() error = %q, want required date context", err.Error())
|
||||||
|
}
|
||||||
|
if !req.Date.IsZero() {
|
||||||
|
t.Fatalf("Date = %s, want unset on error", req.Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
_, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveGenerate() error = nil, want date parse error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `parse date "bad-date" as YYYY-MM-DD`) {
|
||||||
|
t.Fatalf("resolveGenerate() error = %q, want date parse context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateDailyParsesDate(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveGenerate() error = %v", err)
|
t.Fatalf("resolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Report != app.ReportDaily {
|
||||||
|
t.Fatalf("Report = %q, want daily", req.Report)
|
||||||
|
}
|
||||||
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
|
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
|
||||||
t.Fatalf("Date = %s, want 2026-05-29", got)
|
t.Fatalf("Date = %s, want 2026-05-29", got)
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1062,7 @@ func TestResolveGenerateTodayDate(t *testing.T) {
|
|||||||
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
|
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveGenerate() error = %v", err)
|
t.Fatalf("resolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1337,21 @@ JSON
|
|||||||
],
|
],
|
||||||
"precipitation_timing": "The best rain chance is during the morning."
|
"precipitation_timing": "The best rain chance is during the morning."
|
||||||
}
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$prompt" = "weather.daily_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Showers are possible during the selected day.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"A front will keep rain chances in the forecast.",
|
||||||
|
"Temperatures stay seasonable by afternoon."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "Rain is most likely during the afternoon.",
|
||||||
|
"confidence": "Medium"
|
||||||
|
}
|
||||||
JSON
|
JSON
|
||||||
printf 'wrote generated text\n' >&2
|
printf 'wrote generated text\n' >&2
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1345,6 +1398,21 @@ if [ "$1" = "run" ]; then
|
|||||||
"precipitation_timing": "A cold front is moving into the region.",
|
"precipitation_timing": "A cold front is moving into the region.",
|
||||||
"confidence": "Medium"
|
"confidence": "Medium"
|
||||||
}
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$prompt" = "weather.daily_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Showers are possible during the selected day.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"A front will keep rain chances in the forecast.",
|
||||||
|
"Temperatures stay seasonable by afternoon."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "Rain is most likely during the afternoon.",
|
||||||
|
"confidence": "Medium"
|
||||||
|
}
|
||||||
JSON
|
JSON
|
||||||
printf 'wrote generated text\n' >&2
|
printf 'wrote generated text\n' >&2
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ reports:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
}
|
}
|
||||||
items := overrides[report.DailyToday]
|
items := overrides[report.Daily]
|
||||||
if len(items) != 6 {
|
if len(items) != 6 {
|
||||||
t.Fatalf("daily override length = %d, want 6", len(items))
|
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||||
}
|
}
|
||||||
@@ -220,8 +220,8 @@ reports:
|
|||||||
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
|
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := overrides[report.DailyToday]; ok {
|
if _, ok := overrides[report.Daily]; ok {
|
||||||
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.DailyToday])
|
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,18 +314,19 @@ func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||||
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
reports map[string]ReportConfig
|
reports map[string]ReportConfig
|
||||||
wantErr string
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "DuplicateAlias",
|
name: "RetiredDailyReportID",
|
||||||
reports: map[string]ReportConfig{
|
reports: map[string]ReportConfig{
|
||||||
"daily": {},
|
"daily": {},
|
||||||
"daily_today": {},
|
retiredDailyKey: {},
|
||||||
},
|
},
|
||||||
wantErr: "duplicates report override",
|
wantErr: "reports." + retiredDailyKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "TodayAndDailyAreDistinct",
|
name: "TodayAndDailyAreDistinct",
|
||||||
@@ -381,6 +382,7 @@ func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
yaml string
|
yaml string
|
||||||
@@ -425,7 +427,7 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- tomorrow_planning
|
- tomorrow_planning
|
||||||
`,
|
`,
|
||||||
wantErr: `not compatible with report "daily_today"`,
|
wantErr: `not compatible with report "daily"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "RemovedPlaceholderModule",
|
name: "RemovedPlaceholderModule",
|
||||||
@@ -461,17 +463,17 @@ reports:
|
|||||||
wantErr: "options are invalid",
|
wantErr: "options are invalid",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DuplicateReportAlias",
|
name: "RetiredDailyReportID",
|
||||||
yaml: `
|
yaml: `
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
daily_today:
|
` + retiredDailyKey + `:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- current_conditions
|
- current_conditions
|
||||||
`,
|
`,
|
||||||
wantErr: "duplicates report override",
|
wantErr: "reports." + retiredDailyKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HourlyIncompatibleDailyModule",
|
name: "HourlyIncompatibleDailyModule",
|
||||||
@@ -508,6 +510,10 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retiredDailyReportKeyForTest() string {
|
||||||
|
return strings.Join([]string{"daily", "today"}, "_")
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
|
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
strings.Join([]string{"near", "term"}, "_"),
|
strings.Join([]string{"near", "term"}, "_"),
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
|
|
||||||
switch req.Resolved.Definition.ID {
|
switch req.Resolved.Definition.ID {
|
||||||
case report.Hourly:
|
case report.Hourly:
|
||||||
case report.DailyToday, report.Today, report.Tomorrow:
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DerivedFacts{}, err
|
return DerivedFacts{}, err
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Timezone: location.String(),
|
Timezone: location.String(),
|
||||||
@@ -252,8 +252,8 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
|||||||
wantDiscussion []string
|
wantDiscussion []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "daily today",
|
name: "daily",
|
||||||
resolved: resolveForTest(t, report.DailyToday, now, location),
|
resolved: resolveForTest(t, report.Daily, now, location),
|
||||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
},
|
},
|
||||||
@@ -322,7 +322,7 @@ func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
bundle := testBundle(location)
|
bundle := testBundle(location)
|
||||||
bundle.SPCConvectiveOutlooks = nil
|
bundle.SPCConvectiveOutlooks = nil
|
||||||
|
|
||||||
@@ -465,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
|
|||||||
|
|
||||||
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
req := report.ResolveRequest{Now: now, Location: location}
|
||||||
|
if id == report.Daily {
|
||||||
|
req.Date = now
|
||||||
|
}
|
||||||
|
resolved, err := report.Resolve(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve %s: %v", id, err)
|
t.Fatalf("resolve %s: %v", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
schemaIDHourly = "hourly"
|
schemaIDHourly = "hourly"
|
||||||
|
schemaIDDaily = "daily"
|
||||||
schemaIDToday = "today"
|
schemaIDToday = "today"
|
||||||
schemaIDTomorrow = "tomorrow"
|
schemaIDTomorrow = "tomorrow"
|
||||||
|
|
||||||
templateIDHourly = "hourly"
|
templateIDHourly = "hourly"
|
||||||
|
templateIDDaily = "daily"
|
||||||
templateIDToday = "today"
|
templateIDToday = "today"
|
||||||
templateIDTomorrow = "tomorrow"
|
templateIDTomorrow = "tomorrow"
|
||||||
)
|
)
|
||||||
@@ -46,6 +48,12 @@ var catalog = []catalogEntry{
|
|||||||
validate: validateHourly,
|
validate: validateHourly,
|
||||||
renderContextBuilder: buildHourlyContext,
|
renderContextBuilder: buildHourlyContext,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDDaily,
|
||||||
|
templateID: templateIDDaily,
|
||||||
|
validate: validateDaily,
|
||||||
|
renderContextBuilder: buildDailyContext,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
schemaID: schemaIDToday,
|
schemaID: schemaIDToday,
|
||||||
templateID: templateIDToday,
|
templateID: templateIDToday,
|
||||||
@@ -142,6 +150,10 @@ func validateHourly(data []byte) (any, []byte, error) {
|
|||||||
return ValidateHourly(data)
|
return ValidateHourly(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDaily(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateDaily(data)
|
||||||
|
}
|
||||||
|
|
||||||
func validateToday(data []byte) (any, []byte, error) {
|
func validateToday(data []byte) (any, []byte, error) {
|
||||||
return ValidateToday(data)
|
return ValidateToday(data)
|
||||||
}
|
}
|
||||||
@@ -158,6 +170,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing
|
|||||||
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
|
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
daily, ok := generated.(Daily)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
today, ok := generated.(Today)
|
today, ok := generated.(Today)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -119,6 +119,32 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
|
||||||
|
definition := report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
}
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
|
||||||
|
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
|
||||||
|
t.Fatalf("Template() = %q, want Daily template source", template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||||
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +204,29 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
|
||||||
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
|
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
daily, normalized, err := dailyHandler.Validate([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := daily.(Daily); !ok {
|
||||||
|
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
|
||||||
|
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||||
@@ -230,4 +279,46 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), `requires today generated text`) {
|
if !strings.Contains(err.Error(), `requires today generated text`) {
|
||||||
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
|
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires daily generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
|
||||||
|
handler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
|
||||||
|
Summary: "Showers are possible during the selected day.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := ctx.(DailyRenderContext); !ok {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/generatedtext/daily.go
Normal file
37
internal/generatedtext/daily.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Daily struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDaily(data []byte) (Daily, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[Daily](data, "daily")
|
||||||
|
if err != nil {
|
||||||
|
return Daily{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
|
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||||
|
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
||||||
|
if value.Summary == "" {
|
||||||
|
return Daily{}, nil, fmt.Errorf("daily generated text summary is required")
|
||||||
|
}
|
||||||
|
if len(value.ForecastDiscussion) == 0 {
|
||||||
|
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, "daily")
|
||||||
|
if err != nil {
|
||||||
|
return Daily{}, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
111
internal/generatedtext/daily_test.go
Normal file
111
internal/generatedtext/daily_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDailyNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep rain chances in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Showers are possible during the selected day." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": "Showers are possible during the selected day.",
|
||||||
|
"forecast_discussion": ["A front will keep rain chances in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode daily generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateDaily([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDaily() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,14 @@ type TomorrowRenderContext struct {
|
|||||||
Derived facts.DerivedFacts
|
Derived facts.DerivedFacts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DailyRenderContext struct {
|
||||||
|
Report DailyReportContext
|
||||||
|
GeneratedText Daily
|
||||||
|
Modules DailyTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
type TodayRenderContext struct {
|
type TodayRenderContext struct {
|
||||||
Report TodayReportContext
|
Report TodayReportContext
|
||||||
GeneratedText Today
|
GeneratedText Today
|
||||||
@@ -124,6 +132,39 @@ type TomorrowDaypartContext struct {
|
|||||||
Summary briefing.DerivedDaypartSummaryModule
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DailyReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []DailyDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
OutdoorWindows *briefing.OutdoorWindowsModule
|
||||||
|
DailyPlanning *briefing.DailyPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
|
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
|
||||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,6 +197,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err)
|
||||||
|
}
|
||||||
|
if metadata.GeneratedAt.IsZero() {
|
||||||
|
return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required")
|
||||||
|
}
|
||||||
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
|
return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required")
|
||||||
|
}
|
||||||
|
modules, err := dailyTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return DailyRenderContext{
|
||||||
|
Report: DailyReportContext{
|
||||||
|
Title: forecastDayName + "'s Weather",
|
||||||
|
ForecastDate: forecastDate,
|
||||||
|
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
|
||||||
|
ForecastDayName: forecastDayName,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Timezone: metadata.Timezone,
|
||||||
|
},
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
|
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
|
||||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,6 +301,78 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
|
metadata, err := lookup.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := lookup.currentConditions()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := lookup.hourlyForecast()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
daily, err := lookup.derivedDailySummary()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
dayparts, err := lookup.derivedDaypartSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := lookup.precipTiming()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := lookup.alertDigest()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := lookup.spcConvectiveOutlooks()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := lookup.areaForecastDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := lookup.spcConvectiveDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := lookup.weatherStory()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outdoor, err := lookup.outdoorWindows()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.dailyPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return DailyTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
OutdoorWindows: outdoor,
|
||||||
|
DailyPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
|
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
|
||||||
lookup := newModuleSnapshotLookup(snapshot)
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
metadata, err := lookup.metadata()
|
metadata, err := lookup.metadata()
|
||||||
@@ -470,6 +618,14 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule,
|
|||||||
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
|
||||||
|
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
|
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
|
||||||
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
|
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
|
||||||
}
|
}
|
||||||
@@ -500,6 +656,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]DailyDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||||
orderedRows := orderedDaypartRows(dayparts, ordered)
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
|
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
|
||||||
|
|||||||
@@ -260,6 +260,132 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDailyRenderContext(t *testing.T) {
|
||||||
|
metadata := testDailyMetadata()
|
||||||
|
snapshot := testDailySnapshot(t)
|
||||||
|
generated := Daily{
|
||||||
|
Summary: "The selected day starts quiet, then showers become more likely later in the day.",
|
||||||
|
ForecastDiscussion: []string{
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
},
|
||||||
|
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
|
||||||
|
}
|
||||||
|
collected := testCollected()
|
||||||
|
derived := testDailyDerived()
|
||||||
|
|
||||||
|
ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDailyRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Report.Title != "Monday's Weather" {
|
||||||
|
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
|
||||||
|
}
|
||||||
|
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
|
||||||
|
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
|
||||||
|
}
|
||||||
|
if ctx.Report.GeneratedAtLabel != "Saturday, June 13, 2026 at 9:14 AM" {
|
||||||
|
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||||
|
}
|
||||||
|
if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone)
|
||||||
|
}
|
||||||
|
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Daily {
|
||||||
|
t.Fatalf("Modules.Metadata = %#v, want daily metadata", ctx.Modules.Metadata)
|
||||||
|
}
|
||||||
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
|
||||||
|
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
|
||||||
|
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 3 {
|
||||||
|
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
|
||||||
|
}
|
||||||
|
if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" {
|
||||||
|
t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", ctx.Modules.Dayparts)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
|
}
|
||||||
|
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.OutdoorWindows == nil || ctx.Modules.DailyPlanning == nil {
|
||||||
|
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
|
||||||
|
}
|
||||||
|
if ctx.Modules.OutdoorWindows.Best == nil || ctx.Modules.OutdoorWindows.Best.Daypart != "morning" {
|
||||||
|
t.Fatalf("Modules.OutdoorWindows = %#v, want morning best window", ctx.Modules.OutdoorWindows)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." {
|
||||||
|
t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning)
|
||||||
|
}
|
||||||
|
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||||
|
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||||
|
}
|
||||||
|
if len(ctx.Derived.DaypartSummaries) != 2 {
|
||||||
|
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := reporttemplate.Render("daily", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"**Forecast date:** Monday, June 15, 2026",
|
||||||
|
"**Updated:** Saturday, June 13, 2026 at 9:14 AM",
|
||||||
|
"The selected day starts quiet, then showers become more likely later in the day.",
|
||||||
|
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
|
||||||
|
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
|
||||||
|
"- **Evening:** Mostly cloudy, with temperatures in the upper 60s.",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||||
|
"The most likely rain window is from midafternoon into early evening.",
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOrderedText(t, text, []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"## Daypart Forecast",
|
||||||
|
"- **Morning:**",
|
||||||
|
"- **Afternoon:**",
|
||||||
|
"- **Evening:**",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"## Forecast Discussion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||||
|
snapshot, err := module.NewSnapshot(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{
|
||||||
|
Summary: "Dry weather is expected for the selected day.",
|
||||||
|
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
|
||||||
|
}, testCollected(), facts.DerivedFacts{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDailyRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 {
|
||||||
|
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
|
||||||
|
}
|
||||||
|
rendered, err := reporttemplate.Render("daily", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
if strings.Contains(text, "## Precipitation Timing") {
|
||||||
|
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "- No daypart forecast details are available.") {
|
||||||
|
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildTomorrowRenderContext(t *testing.T) {
|
func TestBuildTomorrowRenderContext(t *testing.T) {
|
||||||
metadata := testTomorrowMetadata()
|
metadata := testTomorrowMetadata()
|
||||||
snapshot := testTomorrowSnapshot(t)
|
snapshot := testTomorrowSnapshot(t)
|
||||||
@@ -414,6 +540,26 @@ func testTomorrowMetadata() briefing.Metadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDailyMetadata() briefing.Metadata {
|
||||||
|
generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC)
|
||||||
|
return briefing.Metadata{
|
||||||
|
RunID: "run-daily",
|
||||||
|
ReportID: report.Daily,
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "MO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testTodayMetadata() briefing.Metadata {
|
func testTodayMetadata() briefing.Metadata {
|
||||||
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
|
||||||
return briefing.Metadata{
|
return briefing.Metadata{
|
||||||
@@ -446,6 +592,10 @@ func testTomorrowDerived() facts.DerivedFacts {
|
|||||||
return testCivilDayDerived()
|
return testCivilDayDerived()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDailyDerived() facts.DerivedFacts {
|
||||||
|
return testCivilDayDerived()
|
||||||
|
}
|
||||||
|
|
||||||
func testTodayDerived() facts.DerivedFacts {
|
func testTodayDerived() facts.DerivedFacts {
|
||||||
return testCivilDayDerived()
|
return testCivilDayDerived()
|
||||||
}
|
}
|
||||||
@@ -605,6 +755,154 @@ func testTodaySnapshot(t *testing.T) module.Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDailySnapshot(t *testing.T) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: string(module.Metadata),
|
||||||
|
Value: briefing.MetadataModule{
|
||||||
|
RunID: "run-daily",
|
||||||
|
ReportID: report.Daily,
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
GeneratedAt: testDailyMetadata().GeneratedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: testDailyMetadata().ValidPeriod,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: string(module.CurrentConditions),
|
||||||
|
Value: briefing.CurrentConditionsModule{
|
||||||
|
ConditionTextLower: "clear",
|
||||||
|
TemperatureF: intPtr(58),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: string(module.HourlyForecast),
|
||||||
|
Value: briefing.HourlyForecastModule{
|
||||||
|
Periods: []briefing.HourlyForecastPeriod{
|
||||||
|
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDailySummary,
|
||||||
|
StanzaName: string(module.DerivedDailySummary),
|
||||||
|
Value: briefing.DerivedDailySummaryModule{
|
||||||
|
Date: "Monday, June 15, 2026",
|
||||||
|
HighTempF: intPtr(74),
|
||||||
|
LowTempF: intPtr(61),
|
||||||
|
DailyPrecipitationProbability: intPtr(70),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDaypartSummaries,
|
||||||
|
StanzaName: string(module.DerivedDaypartSummaries),
|
||||||
|
Value: map[string]briefing.DerivedDaypartSummaryModule{
|
||||||
|
"afternoon": {
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "mid 70s",
|
||||||
|
DominantConditionDisplay: "Showers",
|
||||||
|
DominantConditionLower: "showers",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTimeLabel: "3:00 PM",
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
},
|
||||||
|
"evening": {
|
||||||
|
DisplayName: "Evening",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "upper 60s",
|
||||||
|
DominantConditionDisplay: "Mostly cloudy",
|
||||||
|
DominantConditionLower: "mostly cloudy",
|
||||||
|
},
|
||||||
|
"morning": {
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "low 60s",
|
||||||
|
DominantConditionDisplay: "Partly cloudy",
|
||||||
|
DominantConditionLower: "partly cloudy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.PrecipTiming,
|
||||||
|
StanzaName: string(module.PrecipTiming),
|
||||||
|
Value: briefing.PrecipTimingModule{
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTime: "3 PM",
|
||||||
|
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||||
|
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: string(module.AlertDigest),
|
||||||
|
Value: briefing.AlertDigestModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
Value: briefing.SPCConvectiveOutlooksModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
StanzaName: string(module.AreaForecastDiscussion),
|
||||||
|
Value: briefing.AreaForecastDiscussionModule{
|
||||||
|
KeyMessages: []string{"Rain chances increase late."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
Value: briefing.SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: "convective outlook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.WeatherStory,
|
||||||
|
StanzaName: string(module.WeatherStory),
|
||||||
|
Value: briefing.WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
Title: "Rain returns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.OutdoorWindows,
|
||||||
|
StanzaName: string(module.OutdoorWindows),
|
||||||
|
Value: briefing.OutdoorWindowsModule{
|
||||||
|
Best: &briefing.OutdoorWindowModule{
|
||||||
|
Daypart: "morning",
|
||||||
|
Reasons: []string{"quiet weather"},
|
||||||
|
},
|
||||||
|
Worst: &briefing.OutdoorWindowModule{
|
||||||
|
Daypart: "afternoon",
|
||||||
|
Reasons: []string{"high precipitation chance"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DailyPlanning,
|
||||||
|
StanzaName: string(module.DailyPlanning),
|
||||||
|
Value: briefing.DailyPlanningModule{
|
||||||
|
MorningReadiness: []string{"Take sunglasses early."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
func testSnapshot(t *testing.T) module.Snapshot {
|
func testSnapshot(t *testing.T) module.Snapshot {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TodayPlanning ID = "today_planning"
|
TodayPlanning ID = "today_planning"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
|
DailyPlanning ID = "daily_planning"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigItem struct {
|
type ConfigItem struct {
|
||||||
@@ -151,3 +152,4 @@ type SPCConvectiveDiscussionOptions struct{}
|
|||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TodayPlanningOptions struct{}
|
type TodayPlanningOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
type DailyPlanningOptions struct{}
|
||||||
|
|||||||
@@ -100,3 +100,11 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
|
|||||||
_ = SPCConvectiveOutlooksOptions{}
|
_ = SPCConvectiveOutlooksOptions{}
|
||||||
_ = SPCConvectiveDiscussionOptions{}
|
_ = SPCConvectiveDiscussionOptions{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
|
||||||
|
if DailyPlanning != ID("daily_planning") {
|
||||||
|
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DailyPlanningOptions{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ var briefingStanzaCategories = map[string]string{
|
|||||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||||
|
string(module.DailyPlanning): categoryDerivedSummaries,
|
||||||
string(module.TodayPlanning): categoryDerivedSummaries,
|
string(module.TodayPlanning): categoryDerivedSummaries,
|
||||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.SchemaVersion != SchemaVersion {
|
if pkg.SchemaVersion != SchemaVersion {
|
||||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
||||||
}
|
}
|
||||||
if pkg.RunID != "20260529T100000Z_daily_today" {
|
if pkg.RunID != "20260529T100000Z_daily" {
|
||||||
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
||||||
}
|
}
|
||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
@@ -252,10 +252,10 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
|||||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||||
data := []byte(`
|
data := []byte(`
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v2
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
report:
|
report:
|
||||||
id: daily_today
|
id: daily
|
||||||
prompt_id: weather.daily_report
|
prompt_id: weather.daily_generated_text
|
||||||
generated_at: 2026-05-29T10:00:00Z
|
generated_at: 2026-05-29T10:00:00Z
|
||||||
timezone: America/Chicago
|
timezone: America/Chicago
|
||||||
current_local_date: "2026-05-29"
|
current_local_date: "2026-05-29"
|
||||||
@@ -264,7 +264,7 @@ report:
|
|||||||
end: 2026-05-30T05:00:00Z
|
end: 2026-05-30T05:00:00Z
|
||||||
briefing:
|
briefing:
|
||||||
metadata:
|
metadata:
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
raw_data:
|
raw_data:
|
||||||
alert_digest:
|
alert_digest:
|
||||||
checked: true
|
checked: true
|
||||||
@@ -283,10 +283,10 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
|||||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||||
return BuildRequest{
|
return BuildRequest{
|
||||||
Metadata: Metadata{
|
Metadata: Metadata{
|
||||||
RunID: "20260529T100000Z_daily_today",
|
RunID: "20260529T100000Z_daily",
|
||||||
ReportID: report.DailyToday,
|
ReportID: report.Daily,
|
||||||
Variant: "today",
|
Variant: "today",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
@@ -295,7 +295,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Modules: snapshotWithOutputs(t,
|
Modules: snapshotWithOutputs(t,
|
||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
|
||||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func dailyTodayDefinition() Definition {
|
func dailyDefinition() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
ID: DailyToday,
|
ID: Daily,
|
||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
TemplateID: "daily",
|
||||||
ArtifactGroup: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
BatchOutputName: "daily.md",
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
Generated: true,
|
ArtifactGroup: "daily",
|
||||||
CompatiblePriorIDs: []ID{DailyToday},
|
BatchOutputName: "daily.md",
|
||||||
Modules: dailyTodayModules(),
|
Generated: true,
|
||||||
Morning: true,
|
CompatiblePriorIDs: []ID{Daily},
|
||||||
resolve: resolveDailyToday,
|
Modules: dailyModules(),
|
||||||
|
resolve: resolveDaily,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ func tomorrowDefinition() Definition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTodayModules() []module.ConfigItem {
|
func dailyModules() []module.ConfigItem {
|
||||||
return moduleItems(
|
return moduleItems(
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -55,6 +58,7 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
module.SPCConvectiveDiscussion,
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.DailyPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,11 +82,11 @@ func tomorrowModules() []module.ConfigItem {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||||
if !req.Date.IsZero() {
|
if req.Date.IsZero() {
|
||||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
|
||||||
}
|
}
|
||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
type ID string
|
type ID string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
Daily ID = "daily"
|
||||||
Today ID = "today"
|
Today ID = "today"
|
||||||
Tomorrow ID = "tomorrow"
|
Tomorrow ID = "tomorrow"
|
||||||
Hourly ID = "hourly"
|
Hourly ID = "hourly"
|
||||||
ThreeDay ID = "three_day"
|
ThreeDay ID = "three_day"
|
||||||
Weekend ID = "weekend"
|
Weekend ID = "weekend"
|
||||||
Storm ID = "storm"
|
Storm ID = "storm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComparisonStrategy string
|
type ComparisonStrategy string
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
func IDForCommandName(name string) (ID, error) {
|
func IDForCommandName(name string) (ID, error) {
|
||||||
switch name {
|
switch name {
|
||||||
case CommandNameDaily:
|
case CommandNameDaily:
|
||||||
return DailyToday, nil
|
return Daily, nil
|
||||||
case CommandNameToday:
|
case CommandNameToday:
|
||||||
return Today, nil
|
return Today, nil
|
||||||
case CommandNameTomorrow:
|
case CommandNameTomorrow:
|
||||||
@@ -54,8 +54,8 @@ func CommandNames() []string {
|
|||||||
func IDForConfigKey(key string) (ID, error) {
|
func IDForConfigKey(key string) (ID, error) {
|
||||||
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "daily", "daily_today":
|
case "daily":
|
||||||
return DailyToday, nil
|
return Daily, nil
|
||||||
case "today":
|
case "today":
|
||||||
return Today, nil
|
return Today, nil
|
||||||
case "tomorrow":
|
case "tomorrow":
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) {
|
|||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
|
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatal("Resolve() error = nil, want explicit date requirement")
|
||||||
}
|
}
|
||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
|
if !strings.Contains(err.Error(), "requires an explicit date") {
|
||||||
|
t.Fatalf("Resolve() error = %v, want explicit date requirement", err)
|
||||||
|
}
|
||||||
|
_ = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||||
@@ -26,11 +29,23 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
|||||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
date := mustParse("2026-05-31T12:00:00-05:00")
|
date := mustParse("2026-05-31T12:00:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date})
|
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
if resolved.Definition.PromptID != "weather.daily_generated_text" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "daily" {
|
||||||
|
t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "daily" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTodayValidPeriod(t *testing.T) {
|
func TestTodayValidPeriod(t *testing.T) {
|
||||||
@@ -256,7 +271,7 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBatchesDoNotIncludeHourly(t *testing.T) {
|
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
||||||
|
|
||||||
@@ -269,8 +284,8 @@ func TestBatchesDoNotIncludeHourly(t *testing.T) {
|
|||||||
t.Fatalf("BatchReports(evening) error = %v", err)
|
t.Fatalf("BatchReports(evening) error = %v", err)
|
||||||
}
|
}
|
||||||
for _, resolved := range append(morning, evening...) {
|
for _, resolved := range append(morning, evening...) {
|
||||||
if resolved.Definition.ID == Hourly {
|
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
|
||||||
t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID)
|
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +295,7 @@ func TestIDForCommandName(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
want ID
|
want ID
|
||||||
}{
|
}{
|
||||||
{name: "daily", want: DailyToday},
|
{name: "daily", want: Daily},
|
||||||
{name: "today", want: Today},
|
{name: "today", want: Today},
|
||||||
{name: "tomorrow", want: Tomorrow},
|
{name: "tomorrow", want: Tomorrow},
|
||||||
{name: "hourly", want: Hourly},
|
{name: "hourly", want: Hourly},
|
||||||
@@ -323,8 +338,7 @@ func TestIDForConfigKey(t *testing.T) {
|
|||||||
key string
|
key string
|
||||||
want ID
|
want ID
|
||||||
}{
|
}{
|
||||||
{key: "daily", want: DailyToday},
|
{key: "daily", want: Daily},
|
||||||
{key: "daily_today", want: DailyToday},
|
|
||||||
{key: "today", want: Today},
|
{key: "today", want: Today},
|
||||||
{key: "tomorrow", want: Tomorrow},
|
{key: "tomorrow", want: Tomorrow},
|
||||||
{key: "hourly", want: Hourly},
|
{key: "hourly", want: Hourly},
|
||||||
@@ -351,6 +365,10 @@ func TestIDForConfigKey(t *testing.T) {
|
|||||||
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
|
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
|
||||||
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
|
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
|
||||||
}
|
}
|
||||||
|
retiredDailyKey := strings.Join([]string{"daily", "today"}, "_")
|
||||||
|
if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) {
|
||||||
|
t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err)
|
||||||
|
}
|
||||||
dailyID, err := IDForConfigKey("daily")
|
dailyID, err := IDForConfigKey("daily")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IDForConfigKey(daily) error = %v", err)
|
t.Fatalf("IDForConfigKey(daily) error = %v", err)
|
||||||
@@ -418,7 +436,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
||||||
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||||
want := []string{"daily_today", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
||||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||||
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||||
}
|
}
|
||||||
@@ -443,7 +461,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
|
|||||||
if !definition.Generated {
|
if !definition.Generated {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if definition.ID == Hourly || definition.ID == Today || definition.ID == Tomorrow {
|
if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow {
|
||||||
wantTemplate := string(definition.ID)
|
wantTemplate := string(definition.ID)
|
||||||
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
|
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
|
||||||
@@ -478,11 +496,11 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
comparisonStrategy ComparisonStrategy
|
comparisonStrategy ComparisonStrategy
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: Daily,
|
||||||
artifactGroup: "daily",
|
artifactGroup: "daily",
|
||||||
batchOutputName: "daily.md",
|
batchOutputName: "daily.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday},
|
compatiblePriorIDs: []ID{Daily},
|
||||||
comparisonStrategy: CompareSameValidDate,
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -572,7 +590,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
want []module.ID
|
want []module.ID
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: Daily,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -586,6 +604,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.SPCConvectiveDiscussion,
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.DailyPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -703,7 +722,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||||
base := DefaultRegistry()
|
base := DefaultRegistry()
|
||||||
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
||||||
DailyToday: {
|
Daily: {
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.AlertDigest},
|
{ID: module.AlertDigest},
|
||||||
},
|
},
|
||||||
@@ -712,7 +731,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
|||||||
t.Fatalf("WithModuleOverrides() error = %v", err)
|
t.Fatalf("WithModuleOverrides() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
definition, err := overridden.Lookup(DailyToday)
|
definition, err := overridden.Lookup(Daily)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Lookup(overridden) error = %v", err)
|
t.Fatalf("Lookup(overridden) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -720,7 +739,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
|||||||
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
|
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultDefinition, err := base.Lookup(DailyToday)
|
defaultDefinition, err := base.Lookup(Daily)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Lookup(default) error = %v", err)
|
t.Fatalf("Lookup(default) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -743,18 +762,22 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolvedMetadata(t *testing.T) {
|
func TestResolvedMetadata(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
resolved, err := Resolve(Daily, ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
if metadata.ReportID != DailyToday {
|
if metadata.ReportID != Daily {
|
||||||
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
|
t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
|
||||||
}
|
}
|
||||||
if metadata.PromptID != "weather.daily_report" {
|
if metadata.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(metadata.RunID, "daily_today") {
|
if !strings.Contains(metadata.RunID, "daily") {
|
||||||
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Registry struct {
|
|||||||
|
|
||||||
func DefaultRegistry() Registry {
|
func DefaultRegistry() Registry {
|
||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
dailyTodayDefinition(),
|
dailyDefinition(),
|
||||||
todayDefinition(),
|
todayDefinition(),
|
||||||
tomorrowDefinition(),
|
tomorrowDefinition(),
|
||||||
hourlyDefinition(),
|
hourlyDefinition(),
|
||||||
@@ -75,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) All() []Definition {
|
func (r Registry) All() []Definition {
|
||||||
ids := []ID{DailyToday, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
ids := []ID{Daily, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||||
out := make([]Definition, 0, len(ids))
|
out := make([]Definition, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if definition, ok := r.definitions[id]; ok {
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
|||||||
38
internal/reporttemplate/prompts/daily.generated_text.md
Normal file
38
internal/reporttemplate/prompts/daily.generated_text.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
TASK: You are writing structured prose slots for a dated daily weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
|
||||||
|
|
||||||
|
The report focuses on the selected local civil day in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. 1-2 sentences summarizing the main weather story for the selected day.
|
||||||
|
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to the selected day.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# summary
|
||||||
|
|
||||||
|
Lead with the most practical local outcome for the selected day. If an active warning is relevant during the report period, lead with the hazard.
|
||||||
|
|
||||||
|
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
|
||||||
|
|
||||||
|
# forecast_discussion
|
||||||
|
|
||||||
|
Use deterministic module facts and narrative products to explain the most useful details for the selected day.
|
||||||
|
|
||||||
|
Useful context may include:
|
||||||
|
|
||||||
|
- timing of condition changes by daypart or hour
|
||||||
|
- boundaries, forcing, moisture, instability, or storm mode when supported
|
||||||
|
- active alerts or SPC outlooks that apply to the location
|
||||||
|
- planning concerns surfaced by `daily_planning`
|
||||||
|
- confidence or uncertainty
|
||||||
|
|
||||||
|
# precipitation_timing
|
||||||
|
|
||||||
|
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.
|
||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
var templates = map[string]string{
|
var templates = map[string]string{
|
||||||
|
"daily": "templates/daily.md.tmpl",
|
||||||
"hourly": "templates/hourly.md.tmpl",
|
"hourly": "templates/hourly.md.tmpl",
|
||||||
"today": "templates/today.md.tmpl",
|
"today": "templates/today.md.tmpl",
|
||||||
"tomorrow": "templates/tomorrow.md.tmpl",
|
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
var schemas = map[string]string{
|
var schemas = map[string]string{
|
||||||
|
"daily": "schemas/daily.generated_text.schema.json",
|
||||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||||
"today": "schemas/today.generated_text.schema.json",
|
"today": "schemas/today.generated_text.schema.json",
|
||||||
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package reporttemplate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,18 @@ func TestTomorrowTemplateLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyTemplateLookup(t *testing.T) {
|
||||||
|
source, err := Template("daily")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
|
||||||
|
if !strings.Contains(source, want) {
|
||||||
|
t.Fatalf("template missing %q:\n%s", want, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTodayTemplateLookup(t *testing.T) {
|
func TestTodayTemplateLookup(t *testing.T) {
|
||||||
source, err := Template("today")
|
source, err := Template("today")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,6 +97,37 @@ func TestTomorrowSchemaLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailySchemaLookup(t *testing.T) {
|
||||||
|
data, err := Schema("daily")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
}
|
||||||
|
schema := assertSchema(t, data, "summary,forecast_discussion")
|
||||||
|
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("schema property forecast_discussion missing or invalid")
|
||||||
|
}
|
||||||
|
if property["type"] != "array" {
|
||||||
|
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
|
||||||
|
}
|
||||||
|
if property["minItems"] != float64(1) {
|
||||||
|
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
|
||||||
|
}
|
||||||
|
items, ok := property["items"].(map[string]any)
|
||||||
|
if !ok || items["type"] != "string" {
|
||||||
|
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
|
||||||
|
}
|
||||||
|
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
|
||||||
|
property, ok := schema.Properties[field].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("schema property %q missing or invalid", field)
|
||||||
|
}
|
||||||
|
if property["type"] != "string" {
|
||||||
|
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTodaySchemaLookup(t *testing.T) {
|
func TestTodaySchemaLookup(t *testing.T) {
|
||||||
data, err := Schema("today")
|
data, err := Schema("today")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -351,6 +395,85 @@ func TestRenderTomorrow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDaily(t *testing.T) {
|
||||||
|
rendered, err := Render("daily", testDailyRenderContext{
|
||||||
|
Report: testDailyReportContext{
|
||||||
|
Title: "Monday's Weather",
|
||||||
|
ForecastDateLabel: "Monday, June 15, 2026",
|
||||||
|
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
|
||||||
|
},
|
||||||
|
GeneratedText: testDailyGeneratedText{
|
||||||
|
Summary: "The selected day starts dry before showers return later in the day.",
|
||||||
|
ForecastDiscussion: []string{
|
||||||
|
"Clouds increase after sunrise.",
|
||||||
|
"Rain chances peak during the afternoon.",
|
||||||
|
},
|
||||||
|
PrecipitationTiming: "A few showers may linger into early evening.",
|
||||||
|
},
|
||||||
|
Modules: testDailyModules{
|
||||||
|
Dayparts: []testDailyDaypart{
|
||||||
|
{
|
||||||
|
Key: "morning",
|
||||||
|
Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperatureTrend: "rising",
|
||||||
|
TemperatureStartPhraseF: "upper 50s",
|
||||||
|
TemperatureEndPhraseF: "upper 60s",
|
||||||
|
DominantConditionDisplay: "Sunny",
|
||||||
|
DominantConditionLower: "sunny",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "afternoon",
|
||||||
|
Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "upper 70s",
|
||||||
|
DominantConditionDisplay: "Showers",
|
||||||
|
DominantConditionLower: "showers",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTimeLabel: "3:00 PM",
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PrecipTiming: &testPrecipTiming{
|
||||||
|
PrecipitationWindows: []testPrecipWindow{
|
||||||
|
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"**Forecast date:** Monday, June 15, 2026",
|
||||||
|
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
||||||
|
"The selected day starts dry before showers return later in the day.",
|
||||||
|
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
|
||||||
|
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
|
||||||
|
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||||
|
"A few showers may linger into early evening.",
|
||||||
|
"Clouds increase after sunrise.",
|
||||||
|
"Rain chances peak during the afternoon.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOrderedText(t, text, []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"## Daypart Forecast",
|
||||||
|
"- **Morning:**",
|
||||||
|
"- **Afternoon:**",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"## Forecast Discussion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderToday(t *testing.T) {
|
func TestRenderToday(t *testing.T) {
|
||||||
rendered, err := Render("today", testTodayRenderContext{
|
rendered, err := Render("today", testTodayRenderContext{
|
||||||
Report: testTodayReportContext{
|
Report: testTodayReportContext{
|
||||||
@@ -532,17 +655,29 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
||||||
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
|
if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
|
||||||
t.Fatalf("Template() error = %v, want unknown template", err)
|
t.Fatalf("Template() error = %v, want unknown template", err)
|
||||||
}
|
}
|
||||||
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) {
|
if _, err := Schema("missing"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "missing"`) {
|
||||||
t.Fatalf("Schema() error = %v, want unknown schema", err)
|
t.Fatalf("Schema() error = %v, want unknown schema", err)
|
||||||
}
|
}
|
||||||
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
|
if _, err := Render("missing", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
|
||||||
t.Fatalf("Render() error = %v, want unknown template", err)
|
t.Fatalf("Render() error = %v, want unknown template", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPromptAssetExists(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("prompts/daily.generated_text.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Daily prompt asset: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} {
|
||||||
|
if !strings.Contains(string(data), want) {
|
||||||
|
t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderFailsForMissingContextFields(t *testing.T) {
|
func TestRenderFailsForMissingContextFields(t *testing.T) {
|
||||||
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
|
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -571,6 +706,12 @@ type testTodayRenderContext struct {
|
|||||||
Modules testTodayModules
|
Modules testTodayModules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testDailyRenderContext struct {
|
||||||
|
Report testDailyReportContext
|
||||||
|
GeneratedText testDailyGeneratedText
|
||||||
|
Modules testDailyModules
|
||||||
|
}
|
||||||
|
|
||||||
type testReportContext struct {
|
type testReportContext struct {
|
||||||
Title string
|
Title string
|
||||||
LocationName string
|
LocationName string
|
||||||
@@ -597,6 +738,12 @@ type testTodayReportContext struct {
|
|||||||
GeneratedAtLabel string
|
GeneratedAtLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testDailyReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDateLabel string
|
||||||
|
GeneratedAtLabel string
|
||||||
|
}
|
||||||
|
|
||||||
type testTomorrowGeneratedText struct {
|
type testTomorrowGeneratedText struct {
|
||||||
Summary string
|
Summary string
|
||||||
ForecastDiscussion []string
|
ForecastDiscussion []string
|
||||||
@@ -604,6 +751,13 @@ type testTomorrowGeneratedText struct {
|
|||||||
Confidence string
|
Confidence string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testDailyGeneratedText struct {
|
||||||
|
Summary string
|
||||||
|
ForecastDiscussion []string
|
||||||
|
PrecipitationTiming string
|
||||||
|
Confidence string
|
||||||
|
}
|
||||||
|
|
||||||
type testModules struct {
|
type testModules struct {
|
||||||
CurrentConditions *testCurrentConditions
|
CurrentConditions *testCurrentConditions
|
||||||
HourlyForecast *testHourlyForecast
|
HourlyForecast *testHourlyForecast
|
||||||
@@ -627,6 +781,11 @@ type testTodayModules struct {
|
|||||||
TodayPlanning *testTodayPlanning
|
TodayPlanning *testTodayPlanning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testDailyModules struct {
|
||||||
|
Dayparts []testDailyDaypart
|
||||||
|
PrecipTiming *testPrecipTiming
|
||||||
|
}
|
||||||
|
|
||||||
type testTodayPlanning struct {
|
type testTodayPlanning struct {
|
||||||
MorningReadiness []string
|
MorningReadiness []string
|
||||||
CommuteSchoolWorkdayConcerns []string
|
CommuteSchoolWorkdayConcerns []string
|
||||||
@@ -639,6 +798,11 @@ type testTomorrowDaypart struct {
|
|||||||
Summary testDaypartSummary
|
Summary testDaypartSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testDailyDaypart struct {
|
||||||
|
Key string
|
||||||
|
Summary testDaypartSummary
|
||||||
|
}
|
||||||
|
|
||||||
type testDaypartSummary struct {
|
type testDaypartSummary struct {
|
||||||
DisplayName string
|
DisplayName string
|
||||||
TempRangeF string
|
TempRangeF string
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.daily.generated_text.schema.json",
|
||||||
|
"title": "Daily GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"forecast_discussion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"forecast_discussion": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"precipitation_timing": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
internal/reporttemplate/templates/daily.md.tmpl
Normal file
24
internal/reporttemplate/templates/daily.md.tmpl
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# {{ .Report.Title }}
|
||||||
|
|
||||||
|
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||||
|
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||||
|
|
||||||
|
{{ .GeneratedText.Summary }}
|
||||||
|
|
||||||
|
## Daypart Forecast
|
||||||
|
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
|
||||||
|
{{ end }}{{ else }}- No daypart forecast details are available.
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
|
||||||
|
## Precipitation Timing
|
||||||
|
{{ range . }}{{ $window := . }}
|
||||||
|
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
|
||||||
|
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
## Forecast Discussion
|
||||||
|
{{ range .GeneratedText.ForecastDiscussion }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.modules.json"),
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.metadata.json"),
|
||||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
|
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.data_package.yaml"),
|
||||||
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
|
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.render.json"),
|
||||||
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"),
|
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.distributor.json"),
|
||||||
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
|
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.md"),
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(pathsString(paths), want) {
|
if !strings.Contains(pathsString(paths), want) {
|
||||||
t.Fatalf("paths = %#v, want component %q", paths, want)
|
t.Fatalf("paths = %#v, want component %q", paths, want)
|
||||||
@@ -49,6 +49,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
|||||||
validDate string
|
validDate string
|
||||||
runID string
|
runID string
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"),
|
||||||
|
group: "daily",
|
||||||
|
validDate: "2026-05-29",
|
||||||
|
runID: "20260529T100000.000000000Z_daily",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "hourly",
|
name: "hourly",
|
||||||
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
|
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
|
||||||
@@ -243,9 +250,12 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||||
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||||
}
|
}
|
||||||
for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
|
if decoded.GeneratedTextSchemaID != "daily" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want daily", decoded.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
for _, unexpected := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
|
||||||
if strings.Contains(string(data), unexpected) {
|
if strings.Contains(string(data), unexpected) {
|
||||||
t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data))
|
t.Fatalf("metadata JSON includes unsaved generated-text path field %q:\n%s", unexpected, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,9 +557,10 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse time: %v", err)
|
t.Fatalf("parse time: %v", err)
|
||||||
}
|
}
|
||||||
resolved, err := report.DefaultRegistry().Resolve(report.DailyToday, report.ResolveRequest{
|
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||||
Now: now,
|
Now: now,
|
||||||
Location: location,
|
Location: location,
|
||||||
|
Date: now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user