Compare commits
7 Commits
4fac69c9f0
...
0f63159482
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f63159482 | |||
| 2792933833 | |||
| 9261431329 | |||
| 1bfd865333 | |||
| e5af7477af | |||
| 92fcbfcc05 | |||
| ff6aade42c |
@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/module` defines the shared identifiers and data envelopes used for
|
`internal/module` defines the shared identifiers and data envelopes used for
|
||||||
prompt-facing modules. Report definitions use module IDs for composition,
|
report modules. Report definitions use module IDs for composition, module
|
||||||
module builders produce outputs with stanza names, prompt input packages consume
|
builders produce rich outputs with stanza names, prompt input packages consume
|
||||||
snapshots, and Recent Changes compares snapshot stanzas.
|
runtime prompt export values, and Recent Changes compares snapshot stanzas.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -23,8 +23,38 @@ Outputs:
|
|||||||
- typed option structs for registered modules
|
- typed option structs for registered modules
|
||||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||||
|
- runtime-only prompt export values on module outputs
|
||||||
|
- `module.Output.DataPackageValue`, which selects the prompt export value and
|
||||||
|
falls back to the rich value for hand-built or loaded snapshots
|
||||||
- typed stanza lookup through `module.StanzaValue`
|
- typed stanza lookup through `module.StanzaValue`
|
||||||
|
|
||||||
|
## Rich Values And Prompt Exports
|
||||||
|
|
||||||
|
Each `module.Output` has two value surfaces:
|
||||||
|
|
||||||
|
- `Value`: the rich module value used by templates, module snapshots,
|
||||||
|
inspection, Recent Changes, and render contexts.
|
||||||
|
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
|
||||||
|
data packages.
|
||||||
|
|
||||||
|
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
|
||||||
|
module snapshots keep only the rich `value` field so inspection and
|
||||||
|
render-context reconstruction keep the full deterministic template surface.
|
||||||
|
|
||||||
|
The `internal/briefing` module registry attaches prompt export values when it
|
||||||
|
builds module outputs. Modules without a custom exporter use default
|
||||||
|
pass-through behavior, so their prompt value is the same as their rich value.
|
||||||
|
Modules that need cleanup own typed prompt export structs near the module
|
||||||
|
builder. Current custom prompt exports are:
|
||||||
|
|
||||||
|
- `current_conditions`
|
||||||
|
- `hourly_forecast`
|
||||||
|
- `derived_daypart_summaries`
|
||||||
|
|
||||||
|
Custom exporters remove template-only helpers or confusing duplicates from the
|
||||||
|
data package without shrinking the rich module structs used by templates.
|
||||||
|
Exporter failures include module ID and stanza context.
|
||||||
|
|
||||||
## Registered Module IDs
|
## Registered Module IDs
|
||||||
|
|
||||||
The registry recognizes these IDs:
|
The registry recognizes these IDs:
|
||||||
@@ -186,7 +216,7 @@ validation.
|
|||||||
|
|
||||||
## SPC Convective Module Outputs
|
## SPC Convective Module Outputs
|
||||||
|
|
||||||
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
|
`spc_convective_outlooks` emits a risk-product stanza with:
|
||||||
|
|
||||||
- `checked`
|
- `checked`
|
||||||
- `as_of`
|
- `as_of`
|
||||||
@@ -221,7 +251,8 @@ meet the severity threshold.
|
|||||||
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||||
validation rejects missing schema version, missing module IDs, missing stanza
|
validation rejects missing schema version, missing module IDs, missing stanza
|
||||||
names, duplicate module outputs, and duplicate stanza names while preserving
|
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||||
output order.
|
output order. Snapshot JSON contains rich module values only; runtime prompt
|
||||||
|
export values are not persisted.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -242,5 +273,8 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/module` does not import `internal/report`.
|
- `internal/module` does not import `internal/report`.
|
||||||
- Module IDs are stable strings.
|
- Module IDs are stable strings.
|
||||||
- Each emitted module output has exactly one stanza name and one typed value.
|
- Each emitted module output has exactly one stanza name and one rich typed
|
||||||
|
value.
|
||||||
|
- Built module outputs have a data-package value, either from a custom prompt
|
||||||
|
exporter or from default pass-through behavior.
|
||||||
- Snapshot output order is caller-owned and preserved.
|
- Snapshot output order is caller-owned and preserved.
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` converts report metadata, ordered module outputs, Recent
|
||||||
Recent Changes, and source warnings into the `data_package` file passed to
|
Changes, and source warnings into the `data_package` file passed to
|
||||||
Scriptorium.
|
Scriptorium.
|
||||||
|
|
||||||
The persisted data package is YAML with schema version
|
The persisted data package is YAML with schema version
|
||||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
|
||||||
used for inspection and comparison.
|
used for inspection and comparison. Data packages serialize each module
|
||||||
|
output's prompt export value, not necessarily the full rich module value saved
|
||||||
|
in the module snapshot.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ Outputs:
|
|||||||
The YAML shape includes:
|
The YAML shape includes:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: <run_id>
|
run_id: <run_id>
|
||||||
report:
|
report:
|
||||||
id: <report_id>
|
id: <report_id>
|
||||||
@@ -68,6 +70,32 @@ 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.
|
||||||
|
|
||||||
|
## Module Export Boundary
|
||||||
|
|
||||||
|
Data packages are curated prompt inputs. They are not full template render
|
||||||
|
contexts and should not be treated as a dump of every field available to Go
|
||||||
|
templates.
|
||||||
|
|
||||||
|
When module outputs are built by `internal/briefing`, the registry attaches a
|
||||||
|
runtime prompt export value. `internal/promptinput` serializes
|
||||||
|
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
|
||||||
|
prompt export and falls back to the rich `Value` when no prompt export is set,
|
||||||
|
which keeps loaded snapshots and hand-built tests usable.
|
||||||
|
|
||||||
|
Modules without custom export policy use pass-through behavior. Modules with
|
||||||
|
custom exports currently include:
|
||||||
|
|
||||||
|
- `current_conditions`: omits lower-case condition text and duplicate
|
||||||
|
wind-direction text.
|
||||||
|
- `hourly_forecast`: omits hour labels, lower-case description text, and the
|
||||||
|
template precipitation-mention helper while keeping forecast facts.
|
||||||
|
- `derived_daypart_summaries`: omits deterministic sentence-construction
|
||||||
|
helpers while keeping daypart period, condition, temperature trend,
|
||||||
|
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
|
||||||
|
|
||||||
|
The rich module snapshot and generated-template render context still contain
|
||||||
|
the helper fields used by deterministic Markdown templates.
|
||||||
|
|
||||||
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
|
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
|
||||||
use the same package schema and categories when converted into prompt input.
|
use the same package schema and categories when converted into prompt input.
|
||||||
The default hourly module list places
|
The default hourly module list places
|
||||||
@@ -101,8 +129,8 @@ Current categories are:
|
|||||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||||
validation.
|
validation.
|
||||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
choose module prompt export shapes, find prior snapshots, compare changes,
|
||||||
Scriptorium.
|
choose artifact paths, or invoke Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -142,5 +170,8 @@ Inspect:
|
|||||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||||
- Module stanza order is deterministic within each prompt-facing category.
|
- Module stanza order is deterministic within each prompt-facing category.
|
||||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||||
|
- Data-package stanzas use curated module prompt exports when present and rich
|
||||||
|
values only as pass-through or fallback values.
|
||||||
|
- Data packages are narrower than generated-template render contexts.
|
||||||
- Recent Changes are provided by `internal/changes`; this package does not
|
- Recent Changes are provided by `internal/changes`; this package does not
|
||||||
infer changes from rendered report text.
|
infer changes from rendered report text.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
|||||||
```
|
```
|
||||||
|
|
||||||
Generation commands resolve a report period, fetch a Weather API bundle, build
|
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||||
a JSON module snapshot, build a YAML prompt input data package, run
|
a rich JSON module snapshot, build a curated YAML prompt input data package, run
|
||||||
`scriptorium render`, and write managed artifacts under the configured
|
`scriptorium render`, and write managed artifacts under the configured
|
||||||
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||||
managed Markdown report path.
|
managed Markdown report path.
|
||||||
@@ -287,11 +287,12 @@ weatherreporter inspect sources RUN_ID
|
|||||||
|
|
||||||
Use `inspect reports` to find RunIDs and artifact paths. Use
|
Use `inspect reports` to find RunIDs and artifact paths. Use
|
||||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||||
`inspect modules` to review the persisted ordered module snapshot, and
|
`inspect modules` to review the persisted ordered module snapshot with rich
|
||||||
`inspect data-package` to review the structured prompt package used for
|
template-facing values, and `inspect data-package` to review the curated prompt
|
||||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
|
||||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||||
source provenance and warnings without dumping full weather payloads.
|
`inspect sources` to review source provenance and warnings without dumping full
|
||||||
|
weather payloads.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ This roadmap defines the target state for cleaning up module fields exposed in
|
|||||||
YAML data packages. The goal is to keep report templates composable while making
|
YAML data packages. The goal is to keep report templates composable while making
|
||||||
LLM prompt inputs concise, readable, and free of template-only helper fields.
|
LLM prompt inputs concise, readable, and free of template-only helper fields.
|
||||||
|
|
||||||
This feature is not implemented yet. Current data-package behavior remains
|
This feature is implemented. Current data-package behavior is documented in
|
||||||
documented outside `docs/roadmap/`.
|
`docs/internal/prompt-input.md`; the rich module and template boundary is
|
||||||
|
documented in `docs/internal/module.md` and `docs/templates.md`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ falls back to sorted keys for any unmatched entries.
|
|||||||
prompt data package. Module fields are pointers because missing-data policy may
|
prompt data package. Module fields are pointers because missing-data policy may
|
||||||
omit a stanza.
|
omit a stanza.
|
||||||
|
|
||||||
|
Templates render from rich module values, not from the curated YAML data
|
||||||
|
package. Some fields documented below are deterministic wording helpers for
|
||||||
|
Markdown templates and are intentionally omitted from data packages passed to
|
||||||
|
Scriptorium. The data package is a prompt input, while the render context is the
|
||||||
|
template surface.
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
||||||
@@ -270,6 +276,40 @@ Common fields:
|
|||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
|
||||||
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
||||||
|
|
||||||
|
### Daypart Summaries
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
|
||||||
|
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
|
||||||
|
rich `briefing.DerivedDaypartSummaryModule`.
|
||||||
|
|
||||||
|
Common rich daypart fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
|
||||||
|
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
|
||||||
|
|
||||||
|
Template-only daypart helpers such as `TemperaturePhraseF`,
|
||||||
|
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
|
||||||
|
remain available here even though they are not serialized into data-package
|
||||||
|
YAML.
|
||||||
|
|
||||||
### Alert Digest
|
### Alert Digest
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
|
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
|
||||||
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
|
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
|
||||||
}
|
}
|
||||||
|
for _, want := range []string{`"condition_text_lower"`, `"hour_label"`, `"text_description_lower"`, `"mention_precipitation"`} {
|
||||||
|
if !strings.Contains(string(snapshotData), want) {
|
||||||
|
t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"temperature_phrase_f"`, `"dominant_condition_lower"`, `"dominant_condition_display"`, `"max_pop_time_label"`} {
|
||||||
|
if !strings.Contains(string(snapshotData), want) {
|
||||||
|
t.Fatalf("module snapshot missing rich daypart helper field %q:\n%s", want, string(snapshotData))
|
||||||
|
}
|
||||||
|
}
|
||||||
data, err := os.ReadFile(result.DataPackagePath)
|
data, err := os.ReadFile(result.DataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read data package: %v", err)
|
||||||
@@ -169,7 +179,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
|
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
|
||||||
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
|
||||||
!strings.Contains(string(data), "recent_changes:") ||
|
!strings.Contains(string(data), "recent_changes:") ||
|
||||||
!strings.Contains(string(data), "applicable_risk_products:") ||
|
!strings.Contains(string(data), "applicable_risk_products:") ||
|
||||||
!strings.Contains(string(data), "derived_summaries:") ||
|
!strings.Contains(string(data), "derived_summaries:") ||
|
||||||
@@ -222,6 +232,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !ok || current["condition_text"] != "Clear" {
|
if !ok || current["condition_text"] != "Clear" {
|
||||||
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
||||||
}
|
}
|
||||||
|
for _, omitted := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if _, ok := current[omitted]; ok {
|
||||||
|
t.Fatalf("data package current conditions contains helper field %q: %#v", omitted, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
|
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
|
||||||
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
||||||
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
||||||
@@ -230,6 +245,35 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
||||||
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
||||||
}
|
}
|
||||||
|
periods, ok := hourly["periods"].([]any)
|
||||||
|
if !ok || len(periods) == 0 {
|
||||||
|
t.Fatalf("data package hourly periods = %#v, want prompt period rows", hourly["periods"])
|
||||||
|
}
|
||||||
|
firstPeriod, ok := periods[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data package hourly first period = %#v, want mapping", periods[0])
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if _, ok := firstPeriod[omitted]; ok {
|
||||||
|
t.Fatalf("data package hourly period contains helper field %q: %#v", omitted, firstPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dayparts, ok := savedDataPackage.Briefing.Values["derived_daypart_summaries"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data package daypart summaries = %#v, want daypart map", savedDataPackage.Briefing.Values["derived_daypart_summaries"])
|
||||||
|
}
|
||||||
|
morning, ok := dayparts["morning"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data package morning daypart = %#v, want daypart map", dayparts["morning"])
|
||||||
|
}
|
||||||
|
if morning["max_pop_time"] != "6:00 AM" {
|
||||||
|
t.Fatalf("data package morning max_pop_time = %#v, want friendly label", morning["max_pop_time"])
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if _, ok := morning[omitted]; ok {
|
||||||
|
t.Fatalf("data package morning daypart contains helper field %q: %#v", omitted, morning)
|
||||||
|
}
|
||||||
|
}
|
||||||
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
||||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||||
@@ -265,6 +309,80 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
date time.Time
|
||||||
|
now time.Time
|
||||||
|
prompt string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "today",
|
||||||
|
kind: ReportToday,
|
||||||
|
date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||||
|
prompt: "weather.today_generated_text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tomorrow",
|
||||||
|
kind: ReportTomorrow,
|
||||||
|
now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||||
|
prompt: "weather.tomorrow_generated_text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
kind: ReportDaily,
|
||||||
|
date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||||
|
prompt: "weather.daily_generated_text",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := dailyTestConfig(t, server)
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: tt.kind,
|
||||||
|
Date: tt.date,
|
||||||
|
}, tt.now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
renderer := &recordingRenderer{
|
||||||
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: resolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
|
||||||
|
t.Fatalf("renderer calls render=%d structured=%d run=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
|
||||||
|
}
|
||||||
|
if renderer.renderRequest.PromptID != tt.prompt || renderer.structuredRunRequest.PromptID != tt.prompt {
|
||||||
|
t.Fatalf("prompt IDs render=%q structured=%q, want %q", renderer.renderRequest.PromptID, renderer.structuredRunRequest.PromptID, tt.prompt)
|
||||||
|
}
|
||||||
|
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
||||||
|
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
||||||
|
}
|
||||||
|
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
|
||||||
|
t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
|
||||||
|
}
|
||||||
|
assertRichPromptHelperArtifacts(t, result)
|
||||||
|
assertCuratedPromptDataPackage(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
||||||
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
|
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
|
||||||
cfg := dailyTestConfig(t, server)
|
cfg := dailyTestConfig(t, server)
|
||||||
@@ -2611,6 +2729,98 @@ func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertRichPromptHelperArtifacts(t *testing.T, result *ReportResult) {
|
||||||
|
t.Helper()
|
||||||
|
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read module snapshot: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"condition_text_lower"`,
|
||||||
|
`"hour_label"`,
|
||||||
|
`"text_description_lower"`,
|
||||||
|
`"mention_precipitation"`,
|
||||||
|
`"temperature_phrase_f"`,
|
||||||
|
`"dominant_condition_lower"`,
|
||||||
|
`"dominant_condition_display"`,
|
||||||
|
`"max_pop_time_label"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(snapshotData), want) {
|
||||||
|
t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContext, err := os.ReadFile(result.RenderContextPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read render context: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"condition_text_lower"`,
|
||||||
|
`"hour_label"`,
|
||||||
|
`"text_description_lower"`,
|
||||||
|
`"mention_precipitation"`,
|
||||||
|
`"temperature_phrase_f"`,
|
||||||
|
`"dominant_condition_lower"`,
|
||||||
|
`"dominant_condition_display"`,
|
||||||
|
`"max_pop_time_label"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(renderContext), want) {
|
||||||
|
t.Fatalf("render context missing rich helper field %q:\n%s", want, string(renderContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCuratedPromptDataPackage(t *testing.T, result *ReportResult) {
|
||||||
|
t.Helper()
|
||||||
|
data := readDataPackageForTest(t, result)
|
||||||
|
pkg, err := promptinput.LoadYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode data package: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, ok := pkg.Briefing.Values["current_conditions"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("current_conditions = %#v, want prompt map", pkg.Briefing.Values["current_conditions"])
|
||||||
|
}
|
||||||
|
assertMapOmitsKeys(t, "current_conditions", current, "condition_text_lower", "wind_direction_text")
|
||||||
|
|
||||||
|
hourly, ok := pkg.Briefing.Values["hourly_forecast"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("hourly_forecast = %#v, want prompt map", pkg.Briefing.Values["hourly_forecast"])
|
||||||
|
}
|
||||||
|
periods, ok := hourly["periods"].([]any)
|
||||||
|
if !ok || len(periods) == 0 {
|
||||||
|
t.Fatalf("hourly_forecast.periods = %#v, want prompt periods", hourly["periods"])
|
||||||
|
}
|
||||||
|
firstPeriod, ok := periods[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("hourly first period = %#v, want prompt map", periods[0])
|
||||||
|
}
|
||||||
|
assertMapOmitsKeys(t, "hourly_forecast.periods[0]", firstPeriod, "hour_label", "text_description_lower", "mention_precipitation")
|
||||||
|
|
||||||
|
dayparts, ok := pkg.Briefing.Values["derived_daypart_summaries"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("derived_daypart_summaries = %#v, want prompt map", pkg.Briefing.Values["derived_daypart_summaries"])
|
||||||
|
}
|
||||||
|
morning, ok := dayparts["morning"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("derived_daypart_summaries.morning = %#v, want prompt map", dayparts["morning"])
|
||||||
|
}
|
||||||
|
if morning["max_pop_time"] != "6:00 AM" {
|
||||||
|
t.Fatalf("derived_daypart_summaries.morning.max_pop_time = %#v, want friendly label", morning["max_pop_time"])
|
||||||
|
}
|
||||||
|
assertMapOmitsKeys(t, "derived_daypart_summaries.morning", morning, "temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMapOmitsKeys(t *testing.T, name string, value map[string]any, keys ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := value[key]; ok {
|
||||||
|
t.Fatalf("%s contains helper field %q: %#v", name, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
|
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for name, value := range values {
|
for name, value := range values {
|
||||||
|
|||||||
@@ -89,6 +89,48 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||||
|
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||||
|
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||||
|
}
|
||||||
|
period := prompt.Periods[0]
|
||||||
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||||
|
}
|
||||||
|
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||||
@@ -260,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
|
||||||
|
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
|
||||||
|
}
|
||||||
|
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
|
||||||
|
}
|
||||||
|
if prompt.WindDirection != "S" {
|
||||||
|
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -563,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
t.Helper()
|
||||||
|
var value T
|
||||||
|
data, err := json.Marshal(output.DataPackageValue())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module data package value: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatalf("decode module data package value: %v", err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module value: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func mustParseModuleTime(value string) time.Time {
|
func mustParseModuleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ type CurrentConditionsModule struct {
|
|||||||
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrentConditionsPromptExport struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
current := ctx.Collected.Current
|
current := ctx.Collected.Current
|
||||||
if current == nil {
|
if current == nil {
|
||||||
@@ -50,6 +65,27 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(CurrentConditionsModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||||
|
}
|
||||||
|
return CurrentConditionsPromptExport{
|
||||||
|
ConditionText: rich.ConditionText,
|
||||||
|
IsDay: copyBool(rich.IsDay),
|
||||||
|
TemperatureC: copyInt(rich.TemperatureC),
|
||||||
|
TemperatureF: copyInt(rich.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||||
|
DewpointC: copyInt(rich.DewpointC),
|
||||||
|
DewpointF: copyInt(rich.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||||
|
WindDirection: rich.WindDirection,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
return v.ConditionText == "" &&
|
return v.ConditionText == "" &&
|
||||||
v.ConditionTextLower == "" &&
|
v.ConditionTextLower == "" &&
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ type DerivedDaypartSummaryModule struct {
|
|||||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DerivedDaypartSummaryPromptExport struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||||
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||||
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||||
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||||
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||||
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
|
Snow bool `json:"snow,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) {
|
||||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||||
return nil, fmt.Errorf("daypart summary facts are required")
|
return nil, fmt.Errorf("daypart summary facts are required")
|
||||||
@@ -57,6 +85,52 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||||
|
}
|
||||||
|
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||||
|
for key, daypart := range rich {
|
||||||
|
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||||
|
maxPopTime := rich.MaxPopTime
|
||||||
|
if rich.MaxPopTimeLabel != "" {
|
||||||
|
maxPopTime = rich.MaxPopTimeLabel
|
||||||
|
}
|
||||||
|
return DerivedDaypartSummaryPromptExport{
|
||||||
|
Date: rich.Date,
|
||||||
|
DisplayName: rich.DisplayName,
|
||||||
|
PeriodBegins: rich.PeriodBegins,
|
||||||
|
PeriodEnds: rich.PeriodEnds,
|
||||||
|
TempRangeF: rich.TempRangeF,
|
||||||
|
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||||
|
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||||
|
MaxPopTime: maxPopTime,
|
||||||
|
MentionPrecipitation: rich.MentionPrecipitation,
|
||||||
|
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||||
|
MaxWindGustTime: rich.MaxWindGustTime,
|
||||||
|
DominantCondition: rich.DominantCondition,
|
||||||
|
TemperatureTrend: rich.TemperatureTrend,
|
||||||
|
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||||
|
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||||
|
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||||
|
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||||
|
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||||
|
Snow: rich.Snow,
|
||||||
|
Ice: rich.Ice,
|
||||||
|
Fog: rich.Fog,
|
||||||
|
Heat: rich.Heat,
|
||||||
|
Cold: rich.Cold,
|
||||||
|
Wind: rich.Wind,
|
||||||
|
RelevantAlertCount: rich.RelevantAlertCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
temperature := daypartTemperatureDisplay(daypart)
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
|
|||||||
@@ -201,6 +201,126 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||||
|
morning, ok := prompt["morning"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||||
|
}
|
||||||
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||||
|
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||||
|
}
|
||||||
|
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||||
|
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||||
|
}
|
||||||
|
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||||
|
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||||
|
}
|
||||||
|
afternoon := prompt["afternoon"]
|
||||||
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||||
|
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||||
|
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||||
|
if prompt.TemperatureTrend != test.wantTrend ||
|
||||||
|
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||||
|
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
MaxPopTimeLabel: "6:00 AM",
|
||||||
|
})
|
||||||
|
if withLabel.MaxPopTime != "6:00 AM" {
|
||||||
|
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
})
|
||||||
|
if withoutLabel.MaxPopTime != "6 AM" {
|
||||||
|
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ type HourlyForecastModule struct {
|
|||||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptExport struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
HourLabel string `json:"hour_label,omitempty"`
|
HourLabel string `json:"hour_label,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
@@ -59,6 +68,42 @@ type HourlyForecastPeriod struct {
|
|||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptPeriod struct {
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
hourly := ctx.Collected.Hourly
|
hourly := ctx.Collected.Hourly
|
||||||
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||||
@@ -78,6 +123,66 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(HourlyForecastModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||||
|
}
|
||||||
|
return HourlyForecastPromptExport{
|
||||||
|
Product: rich.Product,
|
||||||
|
IssuedAt: rich.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||||
|
SourceLocation: rich.SourceLocation,
|
||||||
|
SourceLocationID: rich.SourceLocationID,
|
||||||
|
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||||
|
if len(periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
out = append(out, HourlyForecastPromptPeriod{
|
||||||
|
PeriodBegins: period.PeriodBegins,
|
||||||
|
PeriodEnds: period.PeriodEnds,
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: period.WindDirection,
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ModuleContext struct {
|
|||||||
|
|
||||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
|
type ModulePromptExporter func(value any) (any, error)
|
||||||
|
|
||||||
type ModuleDefinition struct {
|
type ModuleDefinition struct {
|
||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
|
|||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
Builder ModuleBuilder
|
Builder ModuleBuilder
|
||||||
|
PromptExporter ModulePromptExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleRegistry struct {
|
type ModuleRegistry struct {
|
||||||
definitions map[module.ID]ModuleDefinition
|
definitions map[module.ID]ModuleDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unexpectedPromptExportValue(got any, want any) error {
|
||||||
|
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||||
return NewModuleRegistry(defaultModuleDefinitions())
|
return NewModuleRegistry(defaultModuleDefinitions())
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if output.StanzaName != definition.StanzaName {
|
if output.StanzaName != definition.StanzaName {
|
||||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
}
|
}
|
||||||
|
if definition.PromptExporter == nil {
|
||||||
|
output.PromptValue = output.Value
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
promptValue, err := definition.PromptExporter(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||||
|
}
|
||||||
|
output.PromptValue = promptValue
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
|
PromptExporter: exportCurrentConditionsPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
@@ -289,6 +306,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
|
PromptExporter: exportHourlyForecastPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
@@ -307,6 +325,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
|
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
}
|
}
|
||||||
if output != nil {
|
if output != nil {
|
||||||
|
if output.DataPackageValue() == nil {
|
||||||
|
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||||
|
}
|
||||||
outputs = append(outputs, *output)
|
outputs = append(outputs, *output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,210 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||||
|
customExporters := map[module.ID]struct{}{
|
||||||
|
module.CurrentConditions: {},
|
||||||
|
module.HourlyForecast: {},
|
||||||
|
module.DerivedDaypartSummaries: {},
|
||||||
|
}
|
||||||
|
passThroughExporters := map[module.ID]struct{}{
|
||||||
|
module.Metadata: {},
|
||||||
|
module.NarrativeForecast: {},
|
||||||
|
module.DerivedDailySummary: {},
|
||||||
|
module.PrecipTiming: {},
|
||||||
|
module.AlertDigest: {},
|
||||||
|
module.SPCConvectiveOutlooks: {},
|
||||||
|
module.AreaForecastDiscussion: {},
|
||||||
|
module.SPCConvectiveDiscussion: {},
|
||||||
|
module.WeatherStory: {},
|
||||||
|
module.OutdoorWindows: {},
|
||||||
|
module.TodayPlanning: {},
|
||||||
|
module.TomorrowPlanning: {},
|
||||||
|
module.DailyPlanning: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, definition := range defaultModuleDefinitions() {
|
||||||
|
_, custom := customExporters[definition.ID]
|
||||||
|
_, passThrough := passThroughExporters[definition.ID]
|
||||||
|
if custom == passThrough {
|
||||||
|
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||||
|
}
|
||||||
|
if custom && definition.PromptExporter == nil {
|
||||||
|
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||||
|
}
|
||||||
|
if passThrough && definition.PromptExporter != nil {
|
||||||
|
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want output")
|
||||||
|
}
|
||||||
|
if output.PromptValue != output.Value {
|
||||||
|
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(value any) (any, error) {
|
||||||
|
rich, ok := value.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected rich value type")
|
||||||
|
}
|
||||||
|
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := output.PromptValue.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||||
|
}
|
||||||
|
if got.Message != "rich prompt" {
|
||||||
|
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
if output.Value.(testRegistryValue).Message != "rich" {
|
||||||
|
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return nil, errors.New("unsupported value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||||
|
!strings.Contains(err.Error(), "unsupported value") {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
called = true
|
||||||
|
return testRegistryValue{Message: "prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("PromptExporter called before output validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return testRegistryValue{Message: "prompt-only"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, `"message":"rich"`) {
|
||||||
|
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||||
|
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
||||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
var found bool
|
var found bool
|
||||||
@@ -250,3 +459,15 @@ func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
|||||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRegistryValue struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryModuleContext() ModuleContext {
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: report.Resolved{
|
||||||
|
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily") {
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || !strings.Contains(string(data), "id: daily") {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "location:") ||
|
if !strings.Contains(string(data), "location:") ||
|
||||||
|
|||||||
@@ -44,12 +44,18 @@ func TestBuildHourlyRenderContext(t *testing.T) {
|
|||||||
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
||||||
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
||||||
}
|
}
|
||||||
|
if ctx.Modules.CurrentConditions.ConditionTextLower != "partly cloudy" || ctx.Modules.CurrentConditions.WindDirectionText != "south" {
|
||||||
|
t.Fatalf("Modules.CurrentConditions helpers = %#v, want rich template helper fields", ctx.Modules.CurrentConditions)
|
||||||
|
}
|
||||||
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
||||||
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
|
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
|
||||||
}
|
}
|
||||||
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
|
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
|
||||||
}
|
}
|
||||||
|
if period := ctx.Modules.HourlyForecast.Periods[1]; period.HourLabel != "10:00 AM" || period.TextDescriptionLower != "showers" || !period.MentionPrecipitation {
|
||||||
|
t.Fatalf("Modules.HourlyForecast.Periods[1] helpers = %#v, want rich template helper fields", period)
|
||||||
|
}
|
||||||
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
||||||
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
||||||
}
|
}
|
||||||
@@ -178,6 +184,12 @@ func TestBuildTodayRenderContext(t *testing.T) {
|
|||||||
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
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)
|
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||||
}
|
}
|
||||||
|
if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" {
|
||||||
|
t.Fatalf("today rich morning daypart helpers = %#v, want render-context helper fields", morning)
|
||||||
|
}
|
||||||
|
if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" {
|
||||||
|
t.Fatalf("today rich afternoon daypart = %#v, want max pop time label helper", afternoon)
|
||||||
|
}
|
||||||
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
}
|
}
|
||||||
@@ -305,6 +317,12 @@ func TestBuildDailyRenderContext(t *testing.T) {
|
|||||||
if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" {
|
if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" {
|
||||||
t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", ctx.Modules.Dayparts)
|
t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", ctx.Modules.Dayparts)
|
||||||
}
|
}
|
||||||
|
if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" {
|
||||||
|
t.Fatalf("daily rich morning daypart helpers = %#v, want render-context helper fields", morning)
|
||||||
|
}
|
||||||
|
if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" {
|
||||||
|
t.Fatalf("daily rich afternoon daypart = %#v, want max pop time label helper", afternoon)
|
||||||
|
}
|
||||||
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
}
|
}
|
||||||
@@ -428,6 +446,12 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
|
|||||||
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
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)
|
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||||
}
|
}
|
||||||
|
if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" {
|
||||||
|
t.Fatalf("tomorrow rich morning daypart helpers = %#v, want render-context helper fields", morning)
|
||||||
|
}
|
||||||
|
if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" {
|
||||||
|
t.Fatalf("tomorrow rich afternoon daypart = %#v, want max pop time label helper", afternoon)
|
||||||
|
}
|
||||||
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ type ConfigItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Output struct {
|
type Output struct {
|
||||||
ID ID `json:"id"`
|
ID ID `json:"id"`
|
||||||
StanzaName string `json:"stanzaName"`
|
StanzaName string `json:"stanzaName"`
|
||||||
Value any `json:"value"`
|
Value any `json:"value"`
|
||||||
|
PromptValue any `json:"-" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -45,6 +46,13 @@ type Snapshot struct {
|
|||||||
Outputs []Output `json:"outputs"`
|
Outputs []Output `json:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o Output) DataPackageValue() any {
|
||||||
|
if o.PromptValue != nil {
|
||||||
|
return o.PromptValue
|
||||||
|
}
|
||||||
|
return o.Value
|
||||||
|
}
|
||||||
|
|
||||||
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
||||||
snapshot := Snapshot{
|
snapshot := Snapshot{
|
||||||
SchemaVersion: SnapshotSchemaVersion,
|
SchemaVersion: SnapshotSchemaVersion,
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ type testStanza struct {
|
|||||||
|
|
||||||
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "first", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 10},
|
||||||
|
},
|
||||||
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("json = %s, want %s", got, want)
|
t.Fatalf("json = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(got, "PromptValue") || strings.Contains(got, "promptValue") || strings.Contains(got, "prompt-only") {
|
||||||
|
t.Fatalf("json includes runtime-only prompt value: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputDataPackageValue(t *testing.T) {
|
||||||
|
output := Output{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "rich", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt", Count: 2},
|
||||||
|
}
|
||||||
|
if got := output.DataPackageValue(); got != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want prompt value", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.PromptValue = nil
|
||||||
|
if got := output.DataPackageValue(); got != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
||||||
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
|||||||
|
|
||||||
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "available", Count: 3},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 99},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
const SchemaVersion = "weatherreporter.data_package.v3"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metadataStanza = "metadata"
|
metadataStanza = "metadata"
|
||||||
@@ -133,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
|
|||||||
order := make([]string, 0, len(snapshot.Outputs))
|
order := make([]string, 0, len(snapshot.Outputs))
|
||||||
for _, output := range snapshot.Outputs {
|
for _, output := range snapshot.Outputs {
|
||||||
order = append(order, output.StanzaName)
|
order = append(order, output.StanzaName)
|
||||||
values[output.StanzaName] = output.Value
|
values[output.StanzaName] = output.DataPackageValue()
|
||||||
}
|
}
|
||||||
return BriefingStanzas{Order: order, Values: values}
|
return BriefingStanzas{Order: order, Values: values}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,44 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t,
|
||||||
|
module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: map[string]string{"run_id": "rich"},
|
||||||
|
PromptValue: map[string]string{"run_id": "prompt"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: "current_conditions",
|
||||||
|
Value: map[string]string{"condition_text": "Rich conditions"},
|
||||||
|
PromptValue: map[string]string{"condition_text": "Prompt conditions"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: "alert_digest",
|
||||||
|
Value: map[string]bool{"checked": true},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg, err := Build(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" {
|
||||||
|
t.Fatalf("metadata.run_id = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" {
|
||||||
|
t.Fatalf("current_conditions.condition_text = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got {
|
||||||
|
t.Fatalf("alert_digest.checked = %v, want rich value fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||||
pkg, err := Build(validBuildRequest(t))
|
pkg, err := Build(validBuildRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
|||||||
if string(first) != string(second) {
|
if string(first) != string(second) {
|
||||||
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v3") ||
|
||||||
!strings.Contains(string(first), "briefing:\n") ||
|
!strings.Contains(string(first), "briefing:\n") ||
|
||||||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
||||||
!strings.Contains(string(first), " derived_summaries:\n") ||
|
!strings.Contains(string(first), " derived_summaries:\n") ||
|
||||||
@@ -251,7 +289,7 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||||
data := []byte(`
|
data := []byte(`
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: 20260529T100000Z_daily
|
run_id: 20260529T100000Z_daily
|
||||||
report:
|
report:
|
||||||
id: daily
|
id: daily
|
||||||
@@ -278,6 +316,23 @@ recent_changes:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) {
|
||||||
|
pkg, err := Build(validBuildRequest(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := MarshalYAML(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v3", "weatherreporter.data_package.v2", 1))
|
||||||
|
|
||||||
|
_, err = LoadYAML(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v3") {
|
||||||
|
t.Fatalf("LoadYAML() error = %v, want current schema version error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validBuildRequest(t *testing.T) BuildRequest {
|
func validBuildRequest(t *testing.T) BuildRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -578,6 +578,84 @@ func TestRenderToday(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
template string
|
||||||
|
context any
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
template: "daily",
|
||||||
|
context: testDailyRenderContext{
|
||||||
|
Report: testDailyReportContext{Title: "Daily", ForecastDateLabel: "Monday, June 15, 2026"},
|
||||||
|
GeneratedText: testDailyGeneratedText{Summary: "Daily summary."},
|
||||||
|
Modules: testDailyModules{Dayparts: []testDailyDaypart{
|
||||||
|
{Key: "morning", Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Morning",
|
||||||
|
DominantConditionDisplay: "Showers",
|
||||||
|
TemperaturePhraseF: "upper 60s",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
want: []string{"- **Morning:** Showers, with temperatures in the upper 60s."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "today",
|
||||||
|
template: "today",
|
||||||
|
context: testTodayRenderContext{
|
||||||
|
Report: testTodayReportContext{Title: "Today", ForecastDateLabel: "Monday, June 15, 2026"},
|
||||||
|
GeneratedText: testTomorrowGeneratedText{Summary: "Today summary."},
|
||||||
|
Modules: testTodayModules{Dayparts: []testTomorrowDaypart{
|
||||||
|
{Key: "afternoon", Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
DominantConditionDisplay: "Storms",
|
||||||
|
TemperaturePeakPhraseF: "low 80s",
|
||||||
|
TemperatureTrend: "peaking",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
want: []string{"- **Afternoon:** Storms, with temperatures peaking in the low 80s. Chance of precipitation is 70%."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tomorrow",
|
||||||
|
template: "tomorrow",
|
||||||
|
context: testTomorrowRenderContext{
|
||||||
|
Report: testTomorrowReportContext{Title: "Tomorrow", ForecastDateLabel: "Tuesday, June 16, 2026"},
|
||||||
|
GeneratedText: testTomorrowGeneratedText{Summary: "Tomorrow summary."},
|
||||||
|
Modules: testTomorrowModules{Dayparts: []testTomorrowDaypart{
|
||||||
|
{Key: "morning", Summary: testDaypartSummary{
|
||||||
|
DisplayName: "Morning",
|
||||||
|
DominantConditionDisplay: "Clear",
|
||||||
|
TemperatureStartPhraseF: "upper 50s",
|
||||||
|
TemperatureEndPhraseF: "upper 60s",
|
||||||
|
TemperatureTrend: "rising",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
want: []string{"- **Morning:** Clear, with temperatures rising from the upper 50s to the upper 60s."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rendered, err := Render(tt.template, tt.context)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range tt.want {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
||||||
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||||
Report: testTomorrowReportContext{
|
Report: testTomorrowReportContext{
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
briefingMetadata := stateBriefingMetadata(resolved)
|
briefingMetadata := stateBriefingMetadata(resolved)
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
|
snapshot, err := module.NewSnapshot([]module.Output{{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: map[string]string{"run_id": resolved.Metadata().RunID},
|
||||||
|
PromptValue: map[string]string{"prompt_run_id": resolved.Metadata().RunID},
|
||||||
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,16 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
|
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
|
||||||
t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
|
t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
|
||||||
}
|
}
|
||||||
|
if loadedSnapshot.Outputs[0].PromptValue != nil {
|
||||||
|
t.Fatalf("loaded module snapshot PromptValue = %#v, want omitted runtime value", loadedSnapshot.Outputs[0].PromptValue)
|
||||||
|
}
|
||||||
|
snapshotData, err := os.ReadFile(moduleSnapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read module snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(snapshotData), "prompt_run_id") || strings.Contains(string(snapshotData), "promptValue") || strings.Contains(string(snapshotData), "PromptValue") {
|
||||||
|
t.Fatalf("module snapshot JSON includes runtime-only prompt value:\n%s", string(snapshotData))
|
||||||
|
}
|
||||||
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
|
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadDataPackage() error = %v", err)
|
t.Fatalf("LoadDataPackage() error = %v", err)
|
||||||
@@ -227,6 +242,10 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
|
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
|
||||||
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
|
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
|
||||||
}
|
}
|
||||||
|
metadataStanza, ok := loadedDataPackage.Briefing.Values["metadata"].(map[string]any)
|
||||||
|
if !ok || metadataStanza["prompt_run_id"] != resolved.Metadata().RunID {
|
||||||
|
t.Fatalf("loaded data package metadata = %#v, want runtime prompt value", loadedDataPackage.Briefing.Values["metadata"])
|
||||||
|
}
|
||||||
var decoded Metadata
|
var decoded Metadata
|
||||||
data, err := os.ReadFile(metadataPath)
|
data, err := os.ReadFile(metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user