Compare commits
8 Commits
afe6803a63
...
74bd69834c
| Author | SHA1 | Date | |
|---|---|---|---|
| 74bd69834c | |||
| 98cab70b53 | |||
| dc0172ff82 | |||
| 0b41773017 | |||
| ddda42453f | |||
| 295f06915f | |||
| 120bce3391 | |||
| 386263784c |
10
docs/cli.md
10
docs/cli.md
@@ -39,15 +39,15 @@ Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
|||||||
preflight artifact, managed Markdown report, and metadata under the configured
|
preflight artifact, managed Markdown report, and metadata under the configured
|
||||||
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||||
notification uses the managed report path, not the extra copy. `generate
|
notification uses the managed report path, not the extra copy. `generate
|
||||||
hourly` covers the next six hours in the effective report timezone, does not
|
tomorrow` and `generate hourly` write managed generated-text artifacts,
|
||||||
accept date or event window flags, writes managed generated-text artifacts,
|
validate structured text from Scriptorium, and render the managed Markdown
|
||||||
validates the structured text, and renders the managed Markdown report from the
|
report from embedded templates. `generate hourly` covers the next six hours in
|
||||||
embedded hourly template.
|
the effective report timezone and does not accept date or event window flags.
|
||||||
`generate storm` requires explicit event-window bounds with `--start` and
|
`generate storm` requires explicit event-window bounds with `--start` and
|
||||||
`--end`.
|
`--end`.
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
runs continue independent reports after a failure, print a JSON summary to
|
runs continue independent reports after a failure, print a JSON summary to
|
||||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ snapshot exists and a threshold is crossed.
|
|||||||
report definitions. Omit a report entry to use its default module order.
|
report definitions. Omit a report entry to use its default module order.
|
||||||
|
|
||||||
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
||||||
`weekend`, and `storm`. Canonical report IDs such as `daily_today` and
|
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
|
||||||
`daily_tomorrow` are also accepted.
|
accepted.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,11 @@ recording, with the output path pointing at the raw generated-text JSON
|
|||||||
artifact.
|
artifact.
|
||||||
|
|
||||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||||
preflight save point. Markdown report artifacts are written by Scriptorium to
|
preflight save point. For direct Markdown reports, Scriptorium writes the
|
||||||
the `--out` path. Generated-text raw JSON artifacts are also written by
|
managed Markdown artifact to the `--out` path. For generated-text-template
|
||||||
Scriptorium to the `--out` path; later weatherreporter workflow steps validate
|
reports, Scriptorium writes raw JSON to the `--out` path; later
|
||||||
and render those bytes.
|
weatherreporter workflow steps validate those bytes and render Markdown from an
|
||||||
|
embedded template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ failure context.
|
|||||||
## Batch Workflow
|
## Batch Workflow
|
||||||
|
|
||||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
|
||||||
from report definitions. Batch generation continues independent reports after a
|
from report definitions. Batch generation continues independent reports after a
|
||||||
failure, records each result, writes compact status lines to stderr, emits a
|
failure, records each result, writes compact status lines to stderr, emits a
|
||||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ Outputs:
|
|||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
Tomorrow Report supports the Daily-style civil-day modules plus
|
||||||
|
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
||||||
|
GeneratedText prompt package and embedded Markdown template.
|
||||||
|
|
||||||
Hourly Report supports source and valid-period modules that operate over its
|
Hourly Report supports source and valid-period modules that operate over its
|
||||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ This document describes structured generated-text handling in
|
|||||||
|
|
||||||
`internal/generatedtext` validates structured text returned for
|
`internal/generatedtext` validates structured text returned for
|
||||||
generated-text-template reports and builds curated render contexts for
|
generated-text-template reports and builds curated render contexts for
|
||||||
templates. The first implemented contract is the Hourly Report.
|
templates. The implemented contracts are Tomorrow Report and Hourly Report.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- raw Hourly Report GeneratedText JSON
|
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
|
||||||
- report metadata from `internal/briefing`
|
- report metadata from `internal/briefing`
|
||||||
- a module snapshot from `internal/module`
|
- a module snapshot from `internal/module`
|
||||||
- validated hourly generated text
|
- validated generated text
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Tomorrow` generated text
|
||||||
- typed `Hourly` generated text
|
- typed `Hourly` generated text
|
||||||
- normalized stable JSON for validated hourly generated text
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
|
|
||||||
The hourly generated text JSON accepts:
|
The hourly generated text JSON accepts:
|
||||||
@@ -39,6 +41,22 @@ The hourly generated text JSON accepts:
|
|||||||
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||||
JSON when blank.
|
JSON when blank.
|
||||||
|
|
||||||
|
The Tomorrow generated text JSON accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summary` is required after trimming whitespace. `forecast_discussion` must
|
||||||
|
contain at least one nonblank paragraph after trimming blank items.
|
||||||
|
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||||
|
JSON when blank.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns typed generated-text validation and render-context shaping.
|
- This package owns typed generated-text validation and render-context shaping.
|
||||||
@@ -54,7 +72,8 @@ JSON when blank.
|
|||||||
|
|
||||||
- Malformed generated-text JSON fails with decode context.
|
- Malformed generated-text JSON fails with decode context.
|
||||||
- Unknown generated-text JSON fields fail during decoding.
|
- Unknown generated-text JSON fields fail during decoding.
|
||||||
- Empty required hourly fields fail after trimming whitespace.
|
- Empty required fields fail after trimming whitespace.
|
||||||
|
- Tomorrow forecast discussion fails when no nonblank paragraphs remain.
|
||||||
- Missing optional render-context stanzas become nil module pointers.
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
- Invalid render metadata, including missing timezone, missing generated time,
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
or invalid valid period, fails before template rendering.
|
or invalid valid period, fails before template rendering.
|
||||||
@@ -64,6 +83,7 @@ JSON when blank.
|
|||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/generatedtext/hourly_test.go`
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
- `internal/generatedtext/render_context_test.go`
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ The registry recognizes these IDs:
|
|||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## Tomorrow Composition
|
||||||
|
|
||||||
|
The default Tomorrow Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `tomorrow_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Tomorrow template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
## Hourly Composition
|
## Hourly Composition
|
||||||
|
|
||||||
The default Hourly Report module order is:
|
The default Hourly Report module order is:
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
|
|||||||
labels; canonical report metadata and source timestamps remain structured
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
timestamps where applicable.
|
timestamps where applicable.
|
||||||
|
|
||||||
Hourly Report module snapshots use the same package schema and categories when
|
Tomorrow Report and Hourly Report module snapshots use the same package schema
|
||||||
converted into prompt input. The default hourly module list places
|
and categories when converted into prompt input. The default hourly module list
|
||||||
|
places
|
||||||
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
||||||
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
||||||
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
||||||
@@ -77,6 +78,10 @@ include civil-day summary stanzas. Generated-text and render context artifacts
|
|||||||
are produced later in app orchestration and are not part of the YAML data
|
are produced later in app orchestration and are not part of the YAML data
|
||||||
package.
|
package.
|
||||||
|
|
||||||
|
The default Tomorrow module list includes civil-day summary stanzas,
|
||||||
|
`tomorrow_planning`, and `hourly_forecast` in the data package before
|
||||||
|
structured GeneratedText is requested from Scriptorium.
|
||||||
|
|
||||||
Current categories are:
|
Current categories are:
|
||||||
|
|
||||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ Each report definition declares:
|
|||||||
- default ordered module composition
|
- default ordered module composition
|
||||||
|
|
||||||
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||||
Their template and structured-text schema identifiers are empty. Hourly Report
|
Their template and structured-text schema identifiers are empty. Tomorrow
|
||||||
declares `generated_text_template` with template ID `hourly` and schema ID
|
Report and Hourly Report declare `generated_text_template`; the app uses their
|
||||||
`hourly`; the app uses those identifiers to validate generated text and render
|
template and schema identifiers to validate generated text and render embedded
|
||||||
the embedded hourly template.
|
Markdown templates.
|
||||||
|
|
||||||
## Reports
|
## Reports
|
||||||
|
|
||||||
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||||
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
@@ -49,7 +49,7 @@ All report definitions are eligible for generation.
|
|||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Today covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Daily Tomorrow covers the next local civil day from generation time.
|
- Tomorrow Report covers the next local civil day from generation time.
|
||||||
- Hourly Report covers the half-open six-hour period from generation time in
|
- Hourly Report covers the half-open six-hour period from generation time in
|
||||||
the effective report timezone. The duration is an internal report constant,
|
the effective report timezone. The duration is an internal report constant,
|
||||||
not a configuration field.
|
not a configuration field.
|
||||||
@@ -76,12 +76,13 @@ IDs, then uses the registry for report policy.
|
|||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions. Report
|
output path copying uses batch output names from report definitions. Report
|
||||||
module overrides can use the `hourly` key for Hourly Report.
|
module overrides can use short keys such as `tomorrow` and `hourly`, or
|
||||||
|
canonical report IDs such as `daily_today`.
|
||||||
|
|
||||||
## Batch Membership
|
## Batch Membership
|
||||||
|
|
||||||
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
on Sunday. Evening batches include Daily Tomorrow. Hourly Report is not part
|
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
|
||||||
of a scheduled batch.
|
of a scheduled batch.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
@@ -110,7 +111,9 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report selection goes through the registry.
|
- Report selection goes through the registry.
|
||||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
- Direct Markdown reports have empty template and generated-text schema IDs.
|
||||||
|
- Generated-text-template reports declare prompt, template, and schema IDs in
|
||||||
|
their report definition.
|
||||||
- Valid periods are half-open intervals independent of rendered report text.
|
- Valid periods are half-open intervals independent of rendered report text.
|
||||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||||
default module composition, comparison compatibility, and comparison strategy
|
default module composition, comparison compatibility, and comparison strategy
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ in `internal/reporttemplate`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/reporttemplate` owns repository-native report templates and companion
|
`internal/reporttemplate` owns repository-native report templates and companion
|
||||||
GeneratedText JSON schemas. The first implemented template contract is the
|
GeneratedText JSON schemas. The implemented template contracts are Tomorrow
|
||||||
Hourly Report.
|
Report and Hourly Report.
|
||||||
|
|
||||||
The package embeds assets from:
|
The package embeds assets from:
|
||||||
|
|
||||||
@@ -27,8 +27,9 @@ Outputs:
|
|||||||
- GeneratedText schema bytes for prompt/schema configuration
|
- GeneratedText schema bytes for prompt/schema configuration
|
||||||
- rendered Markdown bytes for app orchestration to persist
|
- rendered Markdown bytes for app orchestration to persist
|
||||||
|
|
||||||
The implemented template ID is `hourly`. The implemented schema ID is also
|
The implemented template IDs are `tomorrow` and `hourly`. The implemented
|
||||||
`hourly`, backed by `hourly.generated_text.schema.json`.
|
schema IDs are also `tomorrow` and `hourly`, backed by
|
||||||
|
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -41,31 +42,37 @@ GeneratedText validation is owned by `internal/generatedtext`. App
|
|||||||
orchestration decides which template and schema IDs apply to a report through
|
orchestration decides which template and schema IDs apply to a report through
|
||||||
`internal/report` definitions.
|
`internal/report` definitions.
|
||||||
|
|
||||||
## Template Contract
|
## Template Contracts
|
||||||
|
|
||||||
Hourly rendering uses a typed render context with:
|
Tomorrow and Hourly rendering use typed render contexts with:
|
||||||
|
|
||||||
- report metadata labels such as title, location, valid period, and generation
|
- report metadata labels such as title, location, valid period, and generation
|
||||||
time
|
time
|
||||||
- validated hourly GeneratedText prose slots
|
- validated GeneratedText prose slots
|
||||||
- deterministic labels derived from module outputs, including current
|
- deterministic labels derived from module outputs, including current
|
||||||
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
||||||
forecast discussion, SPC discussion, and weather story
|
forecast discussion, SPC discussion, and weather story
|
||||||
|
|
||||||
|
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
||||||
|
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
|
||||||
|
forecast discussion generated-text slot. The ordered daypart slice is built in
|
||||||
|
Go so templates do not range over maps.
|
||||||
|
|
||||||
Templates use `text/template` with `missingkey=error`, so missing context fields
|
Templates use `text/template` with `missingkey=error`, so missing context fields
|
||||||
fail rendering instead of producing incomplete Markdown.
|
fail rendering instead of producing incomplete Markdown.
|
||||||
|
|
||||||
## Schema Contract
|
## Schema Contract
|
||||||
|
|
||||||
The hourly GeneratedText schema describes the structured prose Scriptorium is
|
The GeneratedText schemas describe the structured prose Scriptorium is expected
|
||||||
expected to write for the prompt. It requires:
|
to write for each generated-text prompt. Hourly requires:
|
||||||
|
|
||||||
- `summary`
|
- `summary`
|
||||||
- `forecast_discussion`
|
- `forecast_discussion`
|
||||||
|
|
||||||
It allows optional `precipitation_timing` and `confidence`, and rejects
|
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
||||||
additional properties. Weather truth remains in module outputs; GeneratedText is
|
schemas allow optional `precipitation_timing` and `confidence`, and reject
|
||||||
limited to prose slots consumed by the template.
|
additional properties. Weather truth remains in module outputs; GeneratedText
|
||||||
|
is limited to prose slots consumed by the template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
|
|||||||
selects the latest earlier snapshot whose report ID is compatible with the
|
selects the latest earlier snapshot whose report ID is compatible with the
|
||||||
current report definition.
|
current report definition.
|
||||||
|
|
||||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
- Daily Today compares with prior Daily Today snapshots for the same valid
|
||||||
|
local date.
|
||||||
|
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
|
||||||
valid local date.
|
valid local date.
|
||||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||||
date.
|
date.
|
||||||
|
|||||||
@@ -17,23 +17,23 @@ weatherreporter generate weekend
|
|||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Markdown-path generation commands resolve a report period, fetch a Weather API
|
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||||
bundle, build a JSON module snapshot, build a YAML prompt input data package,
|
a JSON module snapshot, build a YAML prompt input data package, run
|
||||||
run `scriptorium render`, run `scriptorium run`, and write managed artifacts
|
`scriptorium render`, and write managed artifacts under the configured
|
||||||
under the configured workspace. When distributor notification is enabled,
|
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||||
weatherreporter uploads the managed Markdown report after report rendering
|
managed Markdown report path.
|
||||||
succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
|
|
||||||
copy for generated reports; it is not used as the distributor upload source.
|
|
||||||
|
|
||||||
`generate hourly` covers the six-hour rolling period from generation time in
|
`generate tomorrow` and `generate hourly` use the generated-text-template
|
||||||
the effective report timezone and is not part of scheduled morning or evening
|
workflow. They run structured `scriptorium run` to raw GeneratedText JSON,
|
||||||
batches. It builds the same module snapshot and data package, runs
|
validate the structured text, save a render context, and render the managed
|
||||||
`scriptorium render` as preflight, runs structured `scriptorium run` to raw
|
Markdown report from embedded templates. `generate hourly` covers the six-hour
|
||||||
GeneratedText JSON, validates the structured text, saves a render context, and
|
rolling period from generation time in the effective report timezone and is not
|
||||||
renders the managed Markdown report from the embedded hourly template. When
|
part of scheduled morning or evening batches.
|
||||||
distributor notification is enabled, hourly uploads the managed Markdown report
|
|
||||||
after final metadata is saved. `--out PATH` writes an extra Markdown copy and
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
is not used as the distributor upload source.
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
|
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||||
|
as the distributor upload source.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ weatherreporter run evening
|
|||||||
```
|
```
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
commands print a JSON summary to stdout, write compact per-report status lines
|
commands print a JSON summary to stdout, write compact per-report status lines
|
||||||
to stderr, continue independent reports after one report fails, and return
|
to stderr, continue independent reports after one report fails, and return
|
||||||
nonzero when any report failed. When notification is configured, the summary and
|
nonzero when any report failed. When notification is configured, the summary and
|
||||||
@@ -80,6 +80,14 @@ workspace/
|
|||||||
<run_id>.generated_text.run.json
|
<run_id>.generated_text.run.json
|
||||||
<run_id>.generated_text.json
|
<run_id>.generated_text.json
|
||||||
<run_id>.render_context.json
|
<run_id>.render_context.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.modules.json
|
||||||
|
<run_id>.metadata.json
|
||||||
|
<run_id>.generated_text.raw.json
|
||||||
|
<run_id>.generated_text.run.json
|
||||||
|
<run_id>.generated_text.json
|
||||||
|
<run_id>.render_context.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -97,6 +105,9 @@ workspace/
|
|||||||
hourly/
|
hourly/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
@@ -113,6 +124,9 @@ workspace/
|
|||||||
hourly/
|
hourly/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
@@ -129,6 +143,9 @@ workspace/
|
|||||||
hourly/
|
hourly/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
@@ -141,6 +158,8 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
hourly/
|
hourly/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
tomorrow/
|
||||||
|
<run_id>.md
|
||||||
storm/
|
storm/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
@@ -165,7 +184,8 @@ Each generated report writes metadata that links:
|
|||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
- generated text schema ID and generated-text artifact paths for Hourly Report
|
- generated text schema ID and generated-text artifact paths for
|
||||||
|
generated-text-template reports
|
||||||
- distributor notification debug artifact path, when notification is attempted
|
- distributor notification debug artifact path, when notification is attempted
|
||||||
|
|
||||||
Batch summaries include report status, error text when applicable, notification
|
Batch summaries include report status, error text when applicable, notification
|
||||||
@@ -246,7 +266,8 @@ source provenance and warnings without dumping full weather payloads.
|
|||||||
Recent Changes are computed from structured module snapshots, not rendered
|
Recent Changes are computed from structured module snapshots, not rendered
|
||||||
Markdown or YAML text.
|
Markdown or YAML text.
|
||||||
|
|
||||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
Daily Today compares with prior Daily Today snapshots for the same valid local
|
||||||
|
date. Tomorrow Report compares with prior Tomorrow Report snapshots for the
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||||
compatible Weekend snapshots for the same weekend window. Hourly Report and
|
compatible Weekend snapshots for the same weekend window. Hourly Report and
|
||||||
|
|||||||
@@ -4,15 +4,17 @@
|
|||||||
|
|
||||||
This guide describes the implemented Markdown report template surface for
|
This guide describes the implemented Markdown report template surface for
|
||||||
`weatherreporter`. It is for maintainers editing embedded report templates,
|
`weatherreporter`. It is for maintainers editing embedded report templates,
|
||||||
especially the hourly report template.
|
especially generated-text-template reports.
|
||||||
|
|
||||||
Templates are Go `text/template` files. The current implemented template is:
|
Templates are Go `text/template` files. The current implemented templates are:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
The hourly template is rendered from a structured `HourlyRenderContext`. Weather
|
Templates are rendered from structured contexts such as `TomorrowRenderContext`
|
||||||
data collection, derivation, module execution, generated text validation, and
|
and `HourlyRenderContext`. Weather data collection, derivation, module
|
||||||
artifact paths are handled before template rendering.
|
execution, generated text validation, and artifact paths are handled before
|
||||||
|
template rendering.
|
||||||
|
|
||||||
## Editing Rules
|
## Editing Rules
|
||||||
|
|
||||||
@@ -77,6 +79,41 @@ Example:
|
|||||||
{{ .GeneratedText.ForecastDiscussion }}
|
{{ .GeneratedText.ForecastDiscussion }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tomorrow Context
|
||||||
|
|
||||||
|
The Tomorrow template receives five top-level values:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
|
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
|
||||||
|
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
|
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||||
|
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||||
|
|
||||||
|
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Tomorrow generated text uses the same `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
|
||||||
|
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
|
||||||
|
be rendered with `range`.
|
||||||
|
|
||||||
|
Tomorrow modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
`.Modules` exposes typed outputs from the same module pipeline used for the
|
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||||
|
|||||||
@@ -185,6 +185,52 @@ prompt, profile, model configuration, or validation issue.
|
|||||||
Relevant docs: [Operations guide](operations.md),
|
Relevant docs: [Operations guide](operations.md),
|
||||||
[Scriptorium integration](integrations/scriptorium.md).
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Generated Text Validation Fails
|
||||||
|
|
||||||
|
Symptom: Tomorrow or Hourly generation fails with generated-text decode,
|
||||||
|
unknown-field, required-field, or multiple-JSON-values context.
|
||||||
|
|
||||||
|
Likely cause: Scriptorium wrote structured JSON that does not match the
|
||||||
|
GeneratedText contract for the selected report.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect the generated-text raw path recorded in metadata, if present.
|
||||||
|
|
||||||
|
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
|
||||||
|
writes the expected structured JSON for the report.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md),
|
||||||
|
[Generated Text internals](internal/generatedtext.md),
|
||||||
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Template Rendering Fails
|
||||||
|
|
||||||
|
Symptom: Tomorrow or Hourly generation fails with report template parsing or
|
||||||
|
execution context after generated text validation succeeds.
|
||||||
|
|
||||||
|
Likely cause: an embedded template references a missing context field or
|
||||||
|
receives a value shape that does not match its typed render context.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
If metadata records generated-text and render-context paths, inspect those
|
||||||
|
artifacts along with the template named by the report definition.
|
||||||
|
|
||||||
|
Safe fix: update the embedded template or render-context builder so the
|
||||||
|
template uses the implemented typed context.
|
||||||
|
|
||||||
|
Relevant docs: [Report Templates](templates.md),
|
||||||
|
[Report Template internals](internal/reporttemplate.md).
|
||||||
|
|
||||||
## Batch Command Returns Nonzero
|
## Batch Command Returns Nonzero
|
||||||
|
|
||||||
Symptom: `run morning` or `run evening` returns nonzero.
|
Symptom: `run morning` or `run evening` returns nonzero.
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
|||||||
case ReportDaily:
|
case ReportDaily:
|
||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case ReportTomorrow:
|
case ReportTomorrow:
|
||||||
return report.DailyTomorrow, nil
|
return report.Tomorrow, nil
|
||||||
case ReportHourly:
|
case ReportHourly:
|
||||||
return report.Hourly, nil
|
return report.Hourly, nil
|
||||||
case ReportThreeDay:
|
case ReportThreeDay:
|
||||||
@@ -566,6 +566,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
metadataPath: metadataPath,
|
metadataPath: metadataPath,
|
||||||
preflightPath: preflightPath,
|
preflightPath: preflightPath,
|
||||||
|
priorSnapshot: priorSnapshot,
|
||||||
recentChanges: recentChanges,
|
recentChanges: recentChanges,
|
||||||
renderResult: renderResult,
|
renderResult: renderResult,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
@@ -646,6 +647,7 @@ type generatedReportRequest struct {
|
|||||||
metadata state.Metadata
|
metadata state.Metadata
|
||||||
metadataPath string
|
metadataPath string
|
||||||
preflightPath string
|
preflightPath string
|
||||||
|
priorSnapshot *state.PriorSnapshot
|
||||||
recentChanges []changes.Change
|
recentChanges []changes.Change
|
||||||
renderResult *scriptorium.RenderResult
|
renderResult *scriptorium.RenderResult
|
||||||
renderer Renderer
|
renderer Renderer
|
||||||
@@ -678,7 +680,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
||||||
}
|
}
|
||||||
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
|
generatedText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
||||||
}
|
}
|
||||||
@@ -692,7 +694,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, hourlyText)
|
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, generatedText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||||
}
|
}
|
||||||
@@ -755,6 +757,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
NotificationPath: notificationPath,
|
NotificationPath: notificationPath,
|
||||||
Metadata: req.metadata,
|
Metadata: req.metadata,
|
||||||
MetadataPath: metadataPath,
|
MetadataPath: metadataPath,
|
||||||
|
PriorSnapshot: req.priorSnapshot,
|
||||||
RecentChanges: req.recentChanges,
|
RecentChanges: req.recentChanges,
|
||||||
RenderResult: req.renderResult,
|
RenderResult: req.renderResult,
|
||||||
StructuredRunResult: structuredResult,
|
StructuredRunResult: structuredResult,
|
||||||
@@ -1085,7 +1088,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||||
}
|
}
|
||||||
switch reportID {
|
switch reportID {
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.DailyToday, report.Tomorrow:
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay:
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
return changes.CompareThreeDay(previous, current, thresholds)
|
||||||
@@ -1110,19 +1113,31 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) {
|
func validateGeneratedText(definition report.Definition, data []byte) (any, []byte, error) {
|
||||||
switch definition.GeneratedTextSchemaID {
|
switch definition.GeneratedTextSchemaID {
|
||||||
case "hourly":
|
case "hourly":
|
||||||
return generatedtext.ValidateHourly(data)
|
return generatedtext.ValidateHourly(data)
|
||||||
|
case "tomorrow":
|
||||||
|
return generatedtext.ValidateTomorrow(data)
|
||||||
default:
|
default:
|
||||||
return generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
return nil, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, hourly generatedtext.Hourly) (any, error) {
|
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, generated any) (any, error) {
|
||||||
switch definition.TemplateID {
|
switch definition.TemplateID {
|
||||||
case "hourly":
|
case "hourly":
|
||||||
|
hourly, ok := generated.(generatedtext.Hourly)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
||||||
|
case "tomorrow":
|
||||||
|
tomorrow, ok := generated.(generatedtext.Tomorrow)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
|
return generatedtext.BuildTomorrowRenderContext(metadata, snapshot, tomorrow, reportFacts.Collected, reportFacts.Derived)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -645,6 +646,62 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
|
||||||
|
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
|
||||||
|
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
|
||||||
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportTomorrow,
|
||||||
|
}, mustParse("2026-05-29T18:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
renderer := &recordingRenderer{
|
||||||
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||||
|
structuredRunBody: validTomorrowGeneratedTextJSON(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: resolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(notifier.requests) != 1 {
|
||||||
|
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
|
||||||
|
}
|
||||||
|
req := notifier.requests[0]
|
||||||
|
if req.ReportID != report.Tomorrow {
|
||||||
|
t.Fatalf("notification ReportID = %q, want tomorrow", req.ReportID)
|
||||||
|
}
|
||||||
|
if req.PipelineID != "weatherreporter.tomorrow.tomorrow" {
|
||||||
|
t.Fatalf("PipelineID = %q, want report/artifact group values", req.PipelineID)
|
||||||
|
}
|
||||||
|
if req.BundleID != "tomorrow.tomorrow.md.tomorrow" {
|
||||||
|
t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID)
|
||||||
|
}
|
||||||
|
wantBundlePaths := []string{"2026-05-30/tomorrow/tomorrow.md"}
|
||||||
|
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
||||||
|
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
|
||||||
|
}
|
||||||
|
if req.ReportPath != result.ReportPath {
|
||||||
|
t.Fatalf("ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
|
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
|
||||||
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
@@ -795,6 +852,65 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedTextValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||||
|
hourlyDefinition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
hourly, normalized, err := validateGeneratedText(hourlyDefinition, []byte(`{
|
||||||
|
"summary": " Storm chances increase. ",
|
||||||
|
"forecast_discussion": " A front will keep the region unsettled. "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateGeneratedText(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := hourly.(generatedtext.Hourly); !ok {
|
||||||
|
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
|
||||||
|
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
|
||||||
|
tomorrow, normalized, err := validateGeneratedText(tomorrowDefinition, []byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateGeneratedText(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tomorrow.(generatedtext.Tomorrow); !ok {
|
||||||
|
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
|
||||||
|
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
|
||||||
|
_, err := buildRenderContext(definition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("buildRenderContext() error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires hourly generated text`) {
|
||||||
|
t.Fatalf("buildRenderContext() error = %v, want hourly generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
|
||||||
|
_, err = buildRenderContext(tomorrowDefinition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Hourly{
|
||||||
|
Summary: "Storm chances increase.",
|
||||||
|
ForecastDiscussion: "A front will keep showers in the forecast.",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("buildRenderContext() error = nil, want tomorrow type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
||||||
|
t.Fatalf("buildRenderContext() error = %v, want tomorrow generated text requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := dailyTestConfig(t, server)
|
cfg := dailyTestConfig(t, server)
|
||||||
@@ -1226,9 +1342,9 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||||
runBody: "# Tomorrow Planning Brief\n",
|
structuredRunBody: validTomorrowGeneratedTextJSON(),
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
@@ -1239,10 +1355,22 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if renderer.runCalls != 0 {
|
||||||
|
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
|
||||||
|
}
|
||||||
|
if renderer.structuredRunCalls != 1 {
|
||||||
|
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
|
||||||
|
}
|
||||||
|
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
|
||||||
|
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
|
||||||
|
}
|
||||||
|
|
||||||
if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" {
|
if result.Metadata.ReportID != report.Tomorrow || result.Metadata.Variant != "tomorrow" {
|
||||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
|
||||||
}
|
}
|
||||||
|
if result.Metadata.GeneratedTextSchemaID != "tomorrow" || result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderedReportPath != result.ReportPath {
|
||||||
|
t.Fatalf("metadata generated-text links = %#v, want tomorrow generated-text artifacts", result.Metadata)
|
||||||
|
}
|
||||||
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
|
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode daily summary: %v", err)
|
t.Fatalf("decode daily summary: %v", err)
|
||||||
@@ -1253,12 +1381,31 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||||
t.Fatal("tomorrow_planning stanza missing")
|
t.Fatal("tomorrow_planning stanza missing")
|
||||||
}
|
}
|
||||||
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") {
|
||||||
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
||||||
}
|
}
|
||||||
|
assertPathsExist(t, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
|
||||||
|
renderContext, err := os.ReadFile(result.RenderContextPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read render context: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"Title": "Saturday's Weather"`, `"GeneratedText": {`, `"forecast_discussion": [`, `"Dayparts": [`} {
|
||||||
|
if !strings.Contains(string(renderContext), want) {
|
||||||
|
t.Fatalf("render context missing %q:\n%s", want, string(renderContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportData, err := os.ReadFile(result.ReportPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"# Saturday's Weather", "## Daypart Forecast", "## Forecast Discussion", "Tomorrow starts with showers before improving."} {
|
||||||
|
if !strings.Contains(string(reportData), want) {
|
||||||
|
t.Fatalf("tomorrow report missing %q:\n%s", want, string(reportData))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -1270,8 +1417,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
priorResolved, err := ResolveGenerate(GenerateRequest{
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportDaily,
|
Report: ReportTomorrow,
|
||||||
Date: mustParse("2026-05-30T12:00:00-05:00"),
|
|
||||||
}, mustParse("2026-05-29T17:00:00-05:00"))
|
}, mustParse("2026-05-29T17:00:00-05:00"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
@@ -1286,9 +1432,9 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
||||||
}
|
}
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||||
runBody: "# Tomorrow Planning Brief\n",
|
structuredRunBody: validTomorrowGeneratedTextJSON(),
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
@@ -1301,10 +1447,54 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.PriorSnapshot == nil {
|
if result.PriorSnapshot == nil {
|
||||||
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot")
|
||||||
}
|
}
|
||||||
if len(result.RecentChanges) == 0 {
|
if len(result.RecentChanges) == 0 {
|
||||||
t.Fatal("RecentChanges length = 0, want changes from compatible prior daily snapshot")
|
t.Fatal("RecentChanges length = 0, want changes from compatible prior tomorrow snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportTomorrow,
|
||||||
|
}, mustParse("2026-05-28T18:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
|
}
|
||||||
|
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||||
|
|
||||||
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: currentResolved,
|
||||||
|
Renderer: successfulRenderer("# Daily Report\n"),
|
||||||
|
Store: store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.PriorSnapshot != nil {
|
||||||
|
t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot)
|
||||||
|
}
|
||||||
|
if len(result.RecentChanges) != 0 {
|
||||||
|
t.Fatalf("RecentChanges = %#v, want none from incompatible prior tomorrow snapshot", result.RecentChanges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1622,8 +1812,8 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "tomorrow",
|
name: "tomorrow",
|
||||||
kind: ReportTomorrow,
|
kind: ReportTomorrow,
|
||||||
wantID: report.DailyTomorrow,
|
wantID: report.Tomorrow,
|
||||||
wantPrompt: "weather.daily_report",
|
wantPrompt: "weather.tomorrow_generated_text",
|
||||||
wantStart: "2026-05-30T00:00:00-05:00",
|
wantStart: "2026-05-30T00:00:00-05:00",
|
||||||
wantEnd: "2026-05-31T00:00:00-05:00",
|
wantEnd: "2026-05-31T00:00:00-05:00",
|
||||||
requestDate: time.Time{},
|
requestDate: time.Time{},
|
||||||
@@ -2061,6 +2251,10 @@ func validHourlyGeneratedTextJSON() string {
|
|||||||
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validTomorrowGeneratedTextJSON() string {
|
||||||
|
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
|
}
|
||||||
|
|
||||||
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
@@ -2402,6 +2596,13 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
|
|||||||
|
|
||||||
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
|
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
|
||||||
r.structuredRunCalls++
|
r.structuredRunCalls++
|
||||||
|
body := validHourlyGeneratedTextJSON()
|
||||||
|
if req.PromptID == "weather.tomorrow_generated_text" {
|
||||||
|
body = validTomorrowGeneratedTextJSON()
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
|
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,24 +11,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
PeriodEnds string `json:"period_ends,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
Heat bool `json:"heat,omitempty"`
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||||
Cold bool `json:"cold,omitempty"`
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
Wind bool `json:"wind,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
Fog bool `json:"fog,omitempty"`
|
||||||
|
Heat bool `json:"heat,omitempty"`
|
||||||
|
Cold bool `json:"cold,omitempty"`
|
||||||
|
Wind bool `json:"wind,omitempty"`
|
||||||
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -46,24 +51,29 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
|
|
||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
Snow: daypart.Indicators.Snow,
|
DominantCondition: daypart.DominantCondition,
|
||||||
Ice: daypart.Indicators.Ice,
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
Fog: daypart.Indicators.Fog,
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
Heat: daypart.Indicators.Heat,
|
Snow: daypart.Indicators.Snow,
|
||||||
Cold: daypart.Indicators.Cold,
|
Ice: daypart.Indicators.Ice,
|
||||||
Wind: daypart.Indicators.Wind,
|
Fog: daypart.Indicators.Fog,
|
||||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
Heat: daypart.Indicators.Heat,
|
||||||
|
Cold: daypart.Indicators.Cold,
|
||||||
|
Wind: daypart.Indicators.Wind,
|
||||||
|
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||||
}
|
}
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
if daypart.MaxPrecipitationProbability != nil {
|
||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
if daypart.PeakWindGust != nil {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -72,6 +82,53 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func temperaturePhraseF(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.Min != nil && value.Max != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if low == nil || high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lowPhrase := temperatureBandPhrase(*low)
|
||||||
|
highPhrase := temperatureBandPhrase(*high)
|
||||||
|
if lowPhrase == highPhrase {
|
||||||
|
return lowPhrase
|
||||||
|
}
|
||||||
|
return lowPhrase + " to " + highPhrase
|
||||||
|
}
|
||||||
|
if value.Min != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
if low == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*low)
|
||||||
|
}
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*high)
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandPhrase(value int) string {
|
||||||
|
decade := (value / 10) * 10
|
||||||
|
remainder := value - decade
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = -remainder
|
||||||
|
}
|
||||||
|
qualifier := "mid"
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
qualifier = "low"
|
||||||
|
case remainder >= 7:
|
||||||
|
qualifier = "upper"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||||
|
}
|
||||||
|
|
||||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
|
|||||||
@@ -169,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||||
}
|
}
|
||||||
|
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.TemperaturePhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||||
|
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||||
|
}
|
||||||
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||||
}
|
}
|
||||||
|
overnight := value["overnight"]
|
||||||
|
if overnight.MentionPrecipitation {
|
||||||
|
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||||
|
}
|
||||||
afternoon := value["afternoon"]
|
afternoon := value["afternoon"]
|
||||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
}
|
}
|
||||||
overnight := value["overnight"]
|
|
||||||
if !overnight.Cold {
|
if !overnight.Cold {
|
||||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||||
}
|
}
|
||||||
@@ -185,7 +191,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
t.Fatalf("marshal daypart summaries: %v", err)
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -195,9 +201,50 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTemperaturePhraseF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value forecast.Range
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single low band",
|
||||||
|
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||||
|
want: "low 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upper value",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||||
|
want: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range across bands",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||||
|
want: "upper 60s to mid 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max only",
|
||||||
|
value: forecast.Range{Max: floatPtr(84)},
|
||||||
|
want: "mid 80s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
value: forecast.Range{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := temperaturePhraseF(test.value); got != test.want {
|
||||||
|
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|
||||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -249,8 +249,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
@@ -276,7 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
},
|
},
|
||||||
@@ -295,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
},
|
},
|
||||||
@@ -379,7 +379,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
SupportedReports: []report.ID{report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func variantForReport(id report.ID) string {
|
|||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
return "today"
|
return "today"
|
||||||
case report.DailyTomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -130,22 +130,22 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Saturday's Weather") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||||
data, err := os.ReadFile(dataPackagePath)
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
if !strings.Contains(string(data), "id: tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
||||||
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob managed report: %v", err)
|
t.Fatalf("glob managed report: %v", err)
|
||||||
}
|
}
|
||||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||||
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,18 +171,18 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
if len(dataPackageMatches) != 1 {
|
if len(dataPackageMatches) != 1 {
|
||||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||||
}
|
}
|
||||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob managed report: %v", err)
|
t.Fatalf("glob managed report: %v", err)
|
||||||
}
|
}
|
||||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||||
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,10 +482,10 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
|||||||
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/runs/distributor-run-1" {
|
if r.URL.Path == "/runs/distributor-run-1" {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.daily","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path != "/v1/pipelines/weatherreporter.daily/upload" {
|
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -521,7 +521,7 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
|||||||
if len(summary.Reports) != 1 {
|
if len(summary.Reports) != 1 {
|
||||||
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
||||||
}
|
}
|
||||||
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.daily" {
|
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" {
|
||||||
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||||
@@ -1176,13 +1176,31 @@ if [ "$1" = "render" ]; then
|
|||||||
fi
|
fi
|
||||||
if [ "$1" = "run" ]; then
|
if [ "$1" = "run" ]; then
|
||||||
out=""
|
out=""
|
||||||
|
prompt=""
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
if [ "$1" = "--out" ]; then
|
if [ "$1" = "--out" ]; then
|
||||||
shift
|
shift
|
||||||
out="$1"
|
out="$1"
|
||||||
|
elif [ "$1" = "--prompt" ]; then
|
||||||
|
shift
|
||||||
|
prompt="$1"
|
||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Tomorrow starts with showers before improving.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"Morning showers should taper as drier air arrives.",
|
||||||
|
"Afternoon conditions trend quieter."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "The best rain chance is during the morning."
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||||
printf 'wrote report\n' >&2
|
printf 'wrote report\n' >&2
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1225,6 +1243,20 @@ if [ "$1" = "run" ]; then
|
|||||||
"precipitation_timing": "A cold front is moving into the region.",
|
"precipitation_timing": "A cold front is moving into the region.",
|
||||||
"confidence": "Medium"
|
"confidence": "Medium"
|
||||||
}
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Tomorrow starts with showers before improving.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"Morning showers should taper as drier air arrives.",
|
||||||
|
"Afternoon conditions trend quieter."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "The best rain chance is during the morning."
|
||||||
|
}
|
||||||
JSON
|
JSON
|
||||||
printf 'wrote generated text\n' >&2
|
printf 'wrote generated text\n' >&2
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -262,6 +262,16 @@ reports:
|
|||||||
`,
|
`,
|
||||||
wantErr: `unknown module "forecast_delta"`,
|
wantErr: `unknown module "forecast_delta"`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "RetiredTomorrowReportID",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily_tomorrow:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: "reports.daily_tomorrow is not a known report",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "InvalidOptions",
|
name: "InvalidOptions",
|
||||||
yaml: `
|
yaml: `
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
|
|||||||
switch normalized {
|
switch normalized {
|
||||||
case "daily", "daily_today":
|
case "daily", "daily_today":
|
||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case "tomorrow", "daily_tomorrow":
|
case "tomorrow":
|
||||||
return report.DailyTomorrow, nil
|
return report.Tomorrow, nil
|
||||||
case "hourly":
|
case "hourly":
|
||||||
return report.Hourly, nil
|
return report.Hourly, nil
|
||||||
case "three_day", "three_day_outlook":
|
case "three_day", "three_day_outlook":
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
|
|
||||||
switch req.Resolved.Definition.ID {
|
switch req.Resolved.Definition.ID {
|
||||||
case report.Hourly:
|
case report.Hourly:
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.DailyToday, report.Tomorrow:
|
||||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DerivedFacts{}, err
|
return DerivedFacts{}, err
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
|
|||||||
|
|
||||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
|
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
|
||||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -259,7 +259,7 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "daily tomorrow",
|
name: "daily tomorrow",
|
||||||
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
|
resolved: resolveForTest(t, report.Tomorrow, now, location),
|
||||||
wantOutlookIDs: []string{"sat-enhanced"},
|
wantOutlookIDs: []string{"sat-enhanced"},
|
||||||
wantDiscussion: []string{"day2"},
|
wantDiscussion: []string{"day2"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package generatedtext
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
@@ -40,6 +44,46 @@ type HourlyTemplateModules struct {
|
|||||||
WeatherStory *briefing.WeatherStoryModule
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TomorrowRenderContext struct {
|
||||||
|
Report TomorrowReportContext
|
||||||
|
GeneratedText Tomorrow
|
||||||
|
Modules TomorrowTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []TomorrowDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
TomorrowPlanning *briefing.TomorrowPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
|
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
|
||||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,6 +116,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err)
|
||||||
|
}
|
||||||
|
if metadata.GeneratedAt.IsZero() {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
|
||||||
|
}
|
||||||
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
|
||||||
|
}
|
||||||
|
modules, err := tomorrowTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return TomorrowRenderContext{
|
||||||
|
Report: TomorrowReportContext{
|
||||||
|
Title: forecastDayName + "'s Weather",
|
||||||
|
ForecastDate: forecastDate,
|
||||||
|
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
|
||||||
|
ForecastDayName: forecastDayName,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Timezone: metadata.Timezone,
|
||||||
|
},
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,6 +201,72 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
|
||||||
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TomorrowTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
TomorrowPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
||||||
output, ok := snapshot.LookupStanza(name)
|
output, ok := snapshot.LookupStanza(name)
|
||||||
if !ok || output.Value == nil {
|
if !ok || output.Value == nil {
|
||||||
@@ -129,11 +274,74 @@ func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
|||||||
}
|
}
|
||||||
value, _, err := module.StanzaValue[T](snapshot, name)
|
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build hourly render context: %w", err)
|
return nil, fmt.Errorf("build render context: %w", err)
|
||||||
}
|
}
|
||||||
return &value, nil
|
return &value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||||
|
if dayparts == nil || len(*dayparts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TomorrowDaypartContext, 0, len(*dayparts))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, daypart := range ordered {
|
||||||
|
for _, key := range daypartModuleKeyCandidates(daypart) {
|
||||||
|
value, ok := (*dayparts)[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, TomorrowDaypartContext{Key: key, Summary: value})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining []string
|
||||||
|
for key := range *dayparts {
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
remaining = append(remaining, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(remaining)
|
||||||
|
for _, key := range remaining {
|
||||||
|
out = append(out, TomorrowDaypartContext{Key: key, Summary: (*dayparts)[key]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
|
||||||
|
key := normalizedDaypartKey(daypart.Name)
|
||||||
|
if key == "" {
|
||||||
|
key = "unnamed"
|
||||||
|
}
|
||||||
|
if daypart.Period.Start.IsZero() {
|
||||||
|
return []string{key}
|
||||||
|
}
|
||||||
|
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedDaypartKey(value string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var out strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
out.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastUnderscore {
|
||||||
|
out.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(out.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
func locationName(metadata briefing.Metadata) string {
|
func locationName(metadata briefing.Metadata) string {
|
||||||
if metadata.Location != nil {
|
if metadata.Location != nil {
|
||||||
if metadata.Location.Name != "" && metadata.Location.Region != "" {
|
if metadata.Location.Name != "" && metadata.Location.Region != "" {
|
||||||
|
|||||||
@@ -114,6 +114,120 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildTomorrowRenderContext(t *testing.T) {
|
||||||
|
metadata := testTomorrowMetadata()
|
||||||
|
snapshot := testTomorrowSnapshot(t)
|
||||||
|
generated := Tomorrow{
|
||||||
|
Summary: "Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||||
|
ForecastDiscussion: []string{
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
},
|
||||||
|
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
|
||||||
|
}
|
||||||
|
collected := testCollected()
|
||||||
|
derived := testTomorrowDerived()
|
||||||
|
|
||||||
|
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Report.Title != "Monday's Weather" {
|
||||||
|
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
|
||||||
|
}
|
||||||
|
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
|
||||||
|
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
|
||||||
|
}
|
||||||
|
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
|
||||||
|
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||||
|
}
|
||||||
|
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
|
||||||
|
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
|
||||||
|
}
|
||||||
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
|
||||||
|
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
|
||||||
|
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
|
||||||
|
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
|
||||||
|
}
|
||||||
|
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
||||||
|
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
|
}
|
||||||
|
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
|
||||||
|
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
|
||||||
|
}
|
||||||
|
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||||
|
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||||
|
}
|
||||||
|
if len(ctx.Derived.DaypartSummaries) != 2 {
|
||||||
|
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"**Forecast date:** Monday, June 15, 2026",
|
||||||
|
"Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||||
|
"- **Morning:** low 60s and partly cloudy.",
|
||||||
|
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||||
|
"The most likely rain window is from midafternoon into early evening.",
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOrderedText(t, text, []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"## Daypart Forecast",
|
||||||
|
"- **Morning:**",
|
||||||
|
"- **Afternoon:**",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"## Forecast Discussion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||||
|
snapshot, err := module.NewSnapshot(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
|
||||||
|
Summary: "Dry weather is expected tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
|
||||||
|
}, testCollected(), facts.DerivedFacts{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
|
||||||
|
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
|
||||||
|
}
|
||||||
|
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
if strings.Contains(text, "## Precipitation Timing") {
|
||||||
|
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "- No daypart forecast details are available.") {
|
||||||
|
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testMetadata() briefing.Metadata {
|
func testMetadata() briefing.Metadata {
|
||||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
return briefing.Metadata{
|
return briefing.Metadata{
|
||||||
@@ -134,6 +248,26 @@ func testMetadata() briefing.Metadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTomorrowMetadata() briefing.Metadata {
|
||||||
|
generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
|
||||||
|
return briefing.Metadata{
|
||||||
|
RunID: "run-tomorrow",
|
||||||
|
ReportID: report.Tomorrow,
|
||||||
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "MO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testCollected() facts.CollectedFacts {
|
func testCollected() facts.CollectedFacts {
|
||||||
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
||||||
}
|
}
|
||||||
@@ -142,6 +276,29 @@ func testDerived() facts.DerivedFacts {
|
|||||||
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
|
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTomorrowDerived() facts.DerivedFacts {
|
||||||
|
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
|
||||||
|
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
||||||
|
return facts.DerivedFacts{
|
||||||
|
DaypartSummaries: []forecast.DaypartSummary{
|
||||||
|
{
|
||||||
|
Name: "morning",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: morningStart,
|
||||||
|
End: afternoonStart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "afternoon",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: afternoonStart,
|
||||||
|
End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSnapshot(t *testing.T) module.Snapshot {
|
func testSnapshot(t *testing.T) module.Snapshot {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
@@ -273,6 +430,129 @@ func testSnapshot(t *testing.T) module.Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTomorrowSnapshot(t *testing.T) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: string(module.Metadata),
|
||||||
|
Value: briefing.MetadataModule{
|
||||||
|
RunID: "run-tomorrow",
|
||||||
|
ReportID: report.Tomorrow,
|
||||||
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
|
GeneratedAt: testTomorrowMetadata().GeneratedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: testTomorrowMetadata().ValidPeriod,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: string(module.CurrentConditions),
|
||||||
|
Value: briefing.CurrentConditionsModule{
|
||||||
|
ConditionTextLower: "clear",
|
||||||
|
TemperatureF: intPtr(58),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: string(module.HourlyForecast),
|
||||||
|
Value: briefing.HourlyForecastModule{
|
||||||
|
Periods: []briefing.HourlyForecastPeriod{
|
||||||
|
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDailySummary,
|
||||||
|
StanzaName: string(module.DerivedDailySummary),
|
||||||
|
Value: briefing.DerivedDailySummaryModule{
|
||||||
|
Date: "Monday, June 15, 2026",
|
||||||
|
HighTempF: intPtr(74),
|
||||||
|
LowTempF: intPtr(61),
|
||||||
|
DailyPrecipitationProbability: intPtr(70),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDaypartSummaries,
|
||||||
|
StanzaName: string(module.DerivedDaypartSummaries),
|
||||||
|
Value: map[string]briefing.DerivedDaypartSummaryModule{
|
||||||
|
"afternoon": {
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
TemperaturePhraseF: "mid 70s",
|
||||||
|
DominantConditionLower: "showers",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTimeLabel: "3:00 PM",
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
},
|
||||||
|
"morning": {
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperaturePhraseF: "low 60s",
|
||||||
|
DominantConditionLower: "partly cloudy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.PrecipTiming,
|
||||||
|
StanzaName: string(module.PrecipTiming),
|
||||||
|
Value: briefing.PrecipTimingModule{
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTime: "3 PM",
|
||||||
|
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||||
|
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: string(module.AlertDigest),
|
||||||
|
Value: briefing.AlertDigestModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
Value: briefing.SPCConvectiveOutlooksModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
StanzaName: string(module.AreaForecastDiscussion),
|
||||||
|
Value: briefing.AreaForecastDiscussionModule{
|
||||||
|
KeyMessages: []string{"Rain chances increase late."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
Value: briefing.SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: "convective outlook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.WeatherStory,
|
||||||
|
StanzaName: string(module.WeatherStory),
|
||||||
|
Value: briefing.WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
Title: "Rain returns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.TomorrowPlanning,
|
||||||
|
StanzaName: string(module.TomorrowPlanning),
|
||||||
|
Value: briefing.TomorrowPlanningModule{
|
||||||
|
MorningReadiness: []string{"Take sunglasses early."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
func floatPtr(value float64) *float64 {
|
func floatPtr(value float64) *float64 {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
@@ -280,3 +560,18 @@ func floatPtr(value float64) *float64 {
|
|||||||
func intPtr(value int) *int {
|
func intPtr(value int) *int {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||||
|
t.Helper()
|
||||||
|
previousIndex := -1
|
||||||
|
for _, want := range ordered {
|
||||||
|
index := strings.Index(text, want)
|
||||||
|
if index < 0 {
|
||||||
|
t.Fatalf("text missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
if index <= previousIndex {
|
||||||
|
t.Fatalf("%q appears out of order in:\n%s", want, text)
|
||||||
|
}
|
||||||
|
previousIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
62
internal/generatedtext/tomorrow.go
Normal file
62
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tomorrow struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
var value Tomorrow
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
|
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||||
|
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
||||||
|
if value.Summary == "" {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
|
||||||
|
}
|
||||||
|
if len(value.ForecastDiscussion) == 0 {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err)
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimNonEmpty(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
111
internal/generatedtext/tomorrow_test.go
Normal file
111
internal/generatedtext/tomorrow_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep showers in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely before sunrise. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Storms become more likely tomorrow." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": "Storms become more likely tomorrow.",
|
||||||
|
"forecast_discussion": ["A front will keep showers in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode tomorrow generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateTomorrow([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateTomorrow() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,27 +15,29 @@ func dailyTodayDefinition() Definition {
|
|||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "daily.md",
|
BatchOutputName: "daily.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
CompatiblePriorIDs: []ID{DailyToday},
|
||||||
Modules: dailyTodayModules(),
|
Modules: dailyTodayModules(),
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveDailyToday,
|
resolve: resolveDailyToday,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowDefinition() Definition {
|
func tomorrowDefinition() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
ID: DailyTomorrow,
|
ID: Tomorrow,
|
||||||
Name: "Tomorrow Planning Brief",
|
Name: "Tomorrow Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
TemplateID: "tomorrow",
|
||||||
ArtifactGroup: "daily",
|
GeneratedTextSchemaID: "tomorrow",
|
||||||
BatchOutputName: "tomorrow.md",
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
Generated: true,
|
ArtifactGroup: "tomorrow",
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
BatchOutputName: "tomorrow.md",
|
||||||
Modules: dailyTomorrowModules(),
|
Generated: true,
|
||||||
Evening: true,
|
CompatiblePriorIDs: []ID{Tomorrow},
|
||||||
resolve: resolveDailyTomorrow,
|
Modules: tomorrowModules(),
|
||||||
|
Evening: true,
|
||||||
|
resolve: resolveTomorrow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
func tomorrowModules() []module.ConfigItem {
|
||||||
return moduleItems(
|
return moduleItems(
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -83,6 +85,6 @@ func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
|||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
type ID string
|
type ID string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
DailyToday ID = "daily_today"
|
||||||
DailyTomorrow ID = "daily_tomorrow"
|
Tomorrow ID = "tomorrow"
|
||||||
Hourly ID = "hourly"
|
Hourly ID = "hourly"
|
||||||
ThreeDay ID = "three_day"
|
ThreeDay ID = "three_day"
|
||||||
Weekend ID = "weekend"
|
Weekend ID = "weekend"
|
||||||
Storm ID = "storm"
|
Storm ID = "storm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComparisonStrategy string
|
type ComparisonStrategy string
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
|
|||||||
}
|
}
|
||||||
return r.resolveIDs(ids, req)
|
return r.resolveIDs(ids, req)
|
||||||
case Evening:
|
case Evening:
|
||||||
return r.resolveIDs([]ID{DailyTomorrow}, req)
|
return r.resolveIDs([]ID{Tomorrow}, req)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,22 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
|
|||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
now := mustParse("2026-05-29T20:00:00-05:00")
|
now := mustParse("2026-05-29T20:00:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location})
|
resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
|
||||||
if resolved.Definition.PromptID != "weather.daily_report" {
|
if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "tomorrow" {
|
||||||
|
t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "tomorrow" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,8 +216,8 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
|||||||
t.Fatalf("BatchReports() error = %v", err)
|
t.Fatalf("BatchReports() error = %v", err)
|
||||||
}
|
}
|
||||||
ids := resolvedIDs(resolved)
|
ids := resolvedIDs(resolved)
|
||||||
if strings.Join(ids, ",") != "daily_tomorrow" {
|
if strings.Join(ids, ",") != "tomorrow" {
|
||||||
t.Fatalf("ids = %v, want daily_tomorrow", ids)
|
t.Fatalf("ids = %v, want tomorrow", ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +252,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
||||||
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||||
want := []string{"daily_today", "daily_tomorrow", "hourly", "three_day", "weekend", "storm"}
|
want := []string{"daily_today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
||||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||||
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||||
}
|
}
|
||||||
@@ -268,15 +277,16 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
|
|||||||
if !definition.Generated {
|
if !definition.Generated {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if definition.ID == Hourly {
|
if definition.ID == Hourly || definition.ID == Tomorrow {
|
||||||
|
wantTemplate := string(definition.ID)
|
||||||
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
|
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
|
||||||
}
|
}
|
||||||
if definition.TemplateID != "hourly" {
|
if definition.TemplateID != wantTemplate {
|
||||||
t.Fatalf("%s TemplateID = %q, want hourly", definition.ID, definition.TemplateID)
|
t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate)
|
||||||
}
|
}
|
||||||
if definition.GeneratedTextSchemaID != "hourly" {
|
if definition.GeneratedTextSchemaID != wantTemplate {
|
||||||
t.Fatalf("%s GeneratedTextSchemaID = %q, want hourly", definition.ID, definition.GeneratedTextSchemaID)
|
t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -306,15 +316,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
artifactGroup: "daily",
|
artifactGroup: "daily",
|
||||||
batchOutputName: "daily.md",
|
batchOutputName: "daily.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{DailyToday},
|
||||||
comparisonStrategy: CompareSameValidDate,
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: Tomorrow,
|
||||||
artifactGroup: "daily",
|
artifactGroup: "tomorrow",
|
||||||
batchOutputName: "tomorrow.md",
|
batchOutputName: "tomorrow.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{Tomorrow},
|
||||||
comparisonStrategy: CompareSameValidDate,
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -406,7 +416,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: Tomorrow,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type Registry struct {
|
|||||||
func DefaultRegistry() Registry {
|
func DefaultRegistry() Registry {
|
||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
dailyTodayDefinition(),
|
dailyTodayDefinition(),
|
||||||
dailyTomorrowDefinition(),
|
tomorrowDefinition(),
|
||||||
hourlyDefinition(),
|
hourlyDefinition(),
|
||||||
threeDayDefinition(),
|
threeDayDefinition(),
|
||||||
weekendDefinition(),
|
weekendDefinition(),
|
||||||
@@ -74,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) All() []Definition {
|
func (r Registry) All() []Definition {
|
||||||
ids := []ID{DailyToday, DailyTomorrow, Hourly, ThreeDay, Weekend, Storm}
|
ids := []ID{DailyToday, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||||
out := make([]Definition, 0, len(ids))
|
out := make([]Definition, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if definition, ok := r.definitions[id]; ok {
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
|||||||
18
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
18
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Tomorrow Generated Text
|
||||||
|
|
||||||
|
Create structured prose for the Tomorrow Report using the supplied
|
||||||
|
weatherreporter data package.
|
||||||
|
|
||||||
|
Return JSON that matches the Scriptorium-registered schema for
|
||||||
|
`weather.tomorrow_generated_text`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `summary`: concise overview of tomorrow's weather.
|
||||||
|
- `forecast_discussion`: one or more paragraph strings explaining the main
|
||||||
|
weather drivers and forecast reasoning.
|
||||||
|
- `precipitation_timing`: optional plain-language precipitation timing context.
|
||||||
|
- `confidence`: optional uncertainty or confidence note.
|
||||||
|
|
||||||
|
Use deterministic facts from the data package for weather details. Do not
|
||||||
|
invent watches, warnings, precipitation windows, temperatures, or timing.
|
||||||
@@ -12,11 +12,13 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
var templates = map[string]string{
|
var templates = map[string]string{
|
||||||
"hourly": "templates/hourly.md.tmpl",
|
"hourly": "templates/hourly.md.tmpl",
|
||||||
|
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
var schemas = map[string]string{
|
var schemas = map[string]string{
|
||||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||||
|
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
func Template(id string) (string, error) {
|
func Template(id string) (string, error) {
|
||||||
|
|||||||
@@ -18,11 +18,78 @@ func TestTemplateLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTomorrowTemplateLookup(t *testing.T) {
|
||||||
|
source, err := Template("tomorrow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
|
||||||
|
if !strings.Contains(source, want) {
|
||||||
|
t.Fatalf("template missing %q:\n%s", want, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSchemaLookup(t *testing.T) {
|
func TestSchemaLookup(t *testing.T) {
|
||||||
data, err := Schema("hourly")
|
data, err := Schema("hourly")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Schema() error = %v", err)
|
t.Fatalf("Schema() error = %v", err)
|
||||||
}
|
}
|
||||||
|
assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTomorrowSchemaLookup(t *testing.T) {
|
||||||
|
data, err := Schema("tomorrow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
}
|
||||||
|
schema := assertSchema(t, data, "summary,forecast_discussion")
|
||||||
|
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("schema property forecast_discussion missing or invalid")
|
||||||
|
}
|
||||||
|
if property["type"] != "array" {
|
||||||
|
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
|
||||||
|
}
|
||||||
|
if property["minItems"] != float64(1) {
|
||||||
|
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
|
||||||
|
}
|
||||||
|
items, ok := property["items"].(map[string]any)
|
||||||
|
if !ok || items["type"] != "string" {
|
||||||
|
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
|
||||||
|
}
|
||||||
|
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
|
||||||
|
property, ok := schema.Properties[field].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("schema property %q missing or invalid", field)
|
||||||
|
}
|
||||||
|
if property["type"] != "string" {
|
||||||
|
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
|
||||||
|
t.Helper()
|
||||||
|
schema := assertSchema(t, data, required)
|
||||||
|
for _, field := range fields {
|
||||||
|
property, ok := schema.Properties[field].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("schema property %q missing or invalid", field)
|
||||||
|
}
|
||||||
|
if property["type"] != "string" {
|
||||||
|
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSchema(t *testing.T, data []byte, required string) struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
AdditionalProperties bool `json:"additionalProperties"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
Properties map[string]any `json:"properties"`
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
var schema struct {
|
var schema struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
AdditionalProperties bool `json:"additionalProperties"`
|
AdditionalProperties bool `json:"additionalProperties"`
|
||||||
@@ -38,18 +105,10 @@ func TestSchemaLookup(t *testing.T) {
|
|||||||
if schema.AdditionalProperties {
|
if schema.AdditionalProperties {
|
||||||
t.Fatal("additionalProperties = true, want false")
|
t.Fatal("additionalProperties = true, want false")
|
||||||
}
|
}
|
||||||
if strings.Join(schema.Required, ",") != "summary,forecast_discussion" {
|
if strings.Join(schema.Required, ",") != required {
|
||||||
t.Fatalf("required = %#v, want summary/forecast_discussion", schema.Required)
|
t.Fatalf("required = %#v, want %s", schema.Required, required)
|
||||||
}
|
|
||||||
for _, field := range []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"} {
|
|
||||||
property, ok := schema.Properties[field].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("schema property %q missing or invalid", field)
|
|
||||||
}
|
|
||||||
if property["type"] != "string" {
|
|
||||||
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return schema
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderHourly(t *testing.T) {
|
func TestRenderHourly(t *testing.T) {
|
||||||
@@ -144,6 +203,119 @@ func TestRenderHourly(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderTomorrow(t *testing.T) {
|
||||||
|
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||||
|
Report: testTomorrowReportContext{
|
||||||
|
Title: "Monday's Weather",
|
||||||
|
ForecastDateLabel: "Monday, June 15, 2026",
|
||||||
|
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
|
||||||
|
},
|
||||||
|
GeneratedText: testTomorrowGeneratedText{
|
||||||
|
Summary: "Tomorrow starts dry before showers return later in the day.",
|
||||||
|
ForecastDiscussion: []string{
|
||||||
|
"Clouds increase after sunrise.",
|
||||||
|
"Rain chances peak during the afternoon.",
|
||||||
|
},
|
||||||
|
PrecipitationTiming: "A few showers may linger into early evening.",
|
||||||
|
},
|
||||||
|
Modules: testTomorrowModules{
|
||||||
|
Dayparts: []testTomorrowDaypart{
|
||||||
|
{
|
||||||
|
Key: "morning",
|
||||||
|
Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperaturePhraseF: "low 60s",
|
||||||
|
DominantConditionLower: "partly cloudy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "afternoon",
|
||||||
|
Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
TemperaturePhraseF: "mid 70s",
|
||||||
|
DominantConditionLower: "showers",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTimeLabel: "3:00 PM",
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PrecipTiming: &testPrecipTiming{
|
||||||
|
PrecipitationWindows: []testPrecipWindow{
|
||||||
|
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"**Forecast date:** Monday, June 15, 2026",
|
||||||
|
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
||||||
|
"Tomorrow starts dry before showers return later in the day.",
|
||||||
|
"- **Morning:** low 60s and partly cloudy.",
|
||||||
|
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.",
|
||||||
|
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||||
|
"A few showers may linger into early evening.",
|
||||||
|
"Clouds increase after sunrise.",
|
||||||
|
"Rain chances peak during the afternoon.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOrderedText(t, text, []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"## Daypart Forecast",
|
||||||
|
"- **Morning:**",
|
||||||
|
"- **Afternoon:**",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"## Forecast Discussion",
|
||||||
|
"Clouds increase after sunrise.",
|
||||||
|
"Rain chances peak during the afternoon.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
||||||
|
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||||
|
Report: testTomorrowReportContext{
|
||||||
|
Title: "Monday's Weather",
|
||||||
|
ForecastDateLabel: "Monday, June 15, 2026",
|
||||||
|
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
|
||||||
|
},
|
||||||
|
GeneratedText: testTomorrowGeneratedText{
|
||||||
|
Summary: "Dry weather is expected tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"High pressure keeps rain chances low."},
|
||||||
|
PrecipitationTiming: "Any stray shower chance is too low to highlight.",
|
||||||
|
},
|
||||||
|
Modules: testTomorrowModules{
|
||||||
|
Dayparts: []testTomorrowDaypart{
|
||||||
|
{
|
||||||
|
Key: "morning",
|
||||||
|
Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperaturePhraseF: "low 60s",
|
||||||
|
DominantConditionLower: "clear",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PrecipTiming: &testPrecipTiming{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, unwanted := range []string{"## Precipitation Timing", "Any stray shower chance is too low to highlight."} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("dry render includes %q:\n%s", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
|
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
|
||||||
rendered, err := Render("hourly", testRenderContext{
|
rendered, err := Render("hourly", testRenderContext{
|
||||||
Report: testReportContext{
|
Report: testReportContext{
|
||||||
@@ -209,6 +381,12 @@ type testRenderContext struct {
|
|||||||
Modules testModules
|
Modules testModules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testTomorrowRenderContext struct {
|
||||||
|
Report testTomorrowReportContext
|
||||||
|
GeneratedText testTomorrowGeneratedText
|
||||||
|
Modules testTomorrowModules
|
||||||
|
}
|
||||||
|
|
||||||
type testReportContext struct {
|
type testReportContext struct {
|
||||||
Title string
|
Title string
|
||||||
LocationName string
|
LocationName string
|
||||||
@@ -223,6 +401,19 @@ type testGeneratedText struct {
|
|||||||
Confidence string
|
Confidence string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testTomorrowReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDateLabel string
|
||||||
|
GeneratedAtLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTomorrowGeneratedText struct {
|
||||||
|
Summary string
|
||||||
|
ForecastDiscussion []string
|
||||||
|
PrecipitationTiming string
|
||||||
|
Confidence string
|
||||||
|
}
|
||||||
|
|
||||||
type testModules struct {
|
type testModules struct {
|
||||||
CurrentConditions *testCurrentConditions
|
CurrentConditions *testCurrentConditions
|
||||||
HourlyForecast *testHourlyForecast
|
HourlyForecast *testHourlyForecast
|
||||||
@@ -234,6 +425,28 @@ type testModules struct {
|
|||||||
WeatherStory *testWeatherStory
|
WeatherStory *testWeatherStory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testTomorrowModules struct {
|
||||||
|
Dayparts []testTomorrowDaypart
|
||||||
|
PrecipTiming *testPrecipTiming
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTomorrowDaypart struct {
|
||||||
|
Key string
|
||||||
|
Summary testDaypartSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type testDaypartSummary struct {
|
||||||
|
DisplayName string
|
||||||
|
TempRangeF string
|
||||||
|
TemperaturePhraseF string
|
||||||
|
MaxPopPercent *int
|
||||||
|
MaxPopTime string
|
||||||
|
MaxPopTimeLabel string
|
||||||
|
MentionPrecipitation bool
|
||||||
|
DominantCondition string
|
||||||
|
DominantConditionLower string
|
||||||
|
}
|
||||||
|
|
||||||
type testCurrentConditions struct {
|
type testCurrentConditions struct {
|
||||||
ConditionText string
|
ConditionText string
|
||||||
ConditionTextLower string
|
ConditionTextLower string
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.tomorrow.generated_text.schema.json",
|
||||||
|
"title": "Tomorrow GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"forecast_discussion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"forecast_discussion": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"precipitation_timing": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/reporttemplate/templates/tomorrow.md.tmpl
Normal file
26
internal/reporttemplate/templates/tomorrow.md.tmpl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# {{ .Report.Title }}
|
||||||
|
|
||||||
|
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||||
|
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||||
|
|
||||||
|
{{ .GeneratedText.Summary }}
|
||||||
|
|
||||||
|
## Daypart Forecast
|
||||||
|
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}
|
||||||
|
- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ if .Summary.TemperaturePhraseF }}{{ .Summary.TemperaturePhraseF }}{{ with .Summary.DominantConditionLower }} and {{ . }}{{ end }}{{ else }}{{ with .Summary.DominantConditionLower }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Precipitation chances peak at {{ . }}%{{ with $daypart.Summary.MaxPopTimeLabel }} at {{ . }}{{ else }}{{ with $daypart.Summary.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}{{ end }}
|
||||||
|
{{ end }}{{ else }}
|
||||||
|
- No daypart forecast details are available.
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
|
||||||
|
## Precipitation Timing
|
||||||
|
{{ range . }}{{ $window := . }}
|
||||||
|
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
|
||||||
|
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
## Forecast Discussion
|
||||||
|
{{ range .GeneratedText.ForecastDiscussion }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
@@ -42,30 +42,57 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|||||||
|
|
||||||
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
|
tests := []struct {
|
||||||
|
name string
|
||||||
paths, err := store.Paths(resolved)
|
resolved report.Resolved
|
||||||
if err != nil {
|
group string
|
||||||
t.Fatalf("Paths() error = %v", err)
|
validDate string
|
||||||
|
runID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "hourly",
|
||||||
|
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
|
||||||
|
group: "hourly",
|
||||||
|
validDate: "2026-05-29",
|
||||||
|
runID: "20260529T100000.000000000Z_hourly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tomorrow",
|
||||||
|
resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"),
|
||||||
|
group: "tomorrow",
|
||||||
|
validDate: "2026-05-30",
|
||||||
|
runID: "20260529T230000.000000000Z_tomorrow",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
runID := "20260529T100000.000000000Z_hourly"
|
for _, tt := range tests {
|
||||||
wants := map[string]string{
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
"GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"),
|
paths, err := store.Paths(tt.resolved)
|
||||||
"GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"),
|
if err != nil {
|
||||||
"GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"),
|
t.Fatalf("Paths() error = %v", err)
|
||||||
"RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"),
|
}
|
||||||
}
|
wants := map[string]string{
|
||||||
got := map[string]string{
|
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
|
||||||
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
|
||||||
"GeneratedTextResult": paths.GeneratedTextResult,
|
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
|
||||||
"GeneratedText": paths.GeneratedText,
|
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
|
||||||
"RenderContext": paths.RenderContext,
|
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
|
||||||
}
|
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
|
||||||
for name, want := range wants {
|
}
|
||||||
if !strings.Contains(got[name], want) {
|
got := map[string]string{
|
||||||
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
|
"DataPackage": paths.DataPackage,
|
||||||
}
|
"RenderedReport": paths.RenderedReport,
|
||||||
|
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
||||||
|
"GeneratedTextResult": paths.GeneratedTextResult,
|
||||||
|
"GeneratedText": paths.GeneratedText,
|
||||||
|
"RenderContext": paths.RenderContext,
|
||||||
|
}
|
||||||
|
for name, want := range wants {
|
||||||
|
if !strings.Contains(got[name], want) {
|
||||||
|
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +592,26 @@ func resolveHourlyAt(t *testing.T, value string) report.Resolved {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveTomorrowAt(t *testing.T, value string) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
location, err := timeutil.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation() error = %v", err)
|
||||||
|
}
|
||||||
|
now, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse time: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||||
return briefing.Metadata{
|
return briefing.Metadata{
|
||||||
RunID: resolved.Metadata().RunID,
|
RunID: resolved.Metadata().RunID,
|
||||||
|
|||||||
Reference in New Issue
Block a user