7 Commits

21 changed files with 1241 additions and 33 deletions

View File

@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
## Purpose
`internal/module` defines the shared identifiers and data envelopes used for
prompt-facing modules. Report definitions use module IDs for composition,
module builders produce outputs with stanza names, prompt input packages consume
snapshots, and Recent Changes compares snapshot stanzas.
report modules. Report definitions use module IDs for composition, module
builders produce rich outputs with stanza names, prompt input packages consume
runtime prompt export values, and Recent Changes compares snapshot stanzas.
## Inputs And Outputs
@@ -23,8 +23,38 @@ Outputs:
- typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
- 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`
## 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
The registry recognizes these IDs:
@@ -186,7 +216,7 @@ validation.
## 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`
- `as_of`
@@ -221,7 +251,8 @@ meet the severity threshold.
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza
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
@@ -242,5 +273,8 @@ Inspect:
- `internal/module` does not import `internal/report`.
- 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.

View File

@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
## Purpose
`internal/promptinput` converts report metadata, an ordered module snapshot,
Recent Changes, and source warnings into the `data_package` file passed to
`internal/promptinput` converts report metadata, ordered module outputs, Recent
Changes, and source warnings into the `data_package` file passed to
Scriptorium.
The persisted data package is YAML with schema version
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
used for inspection and comparison.
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
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
@@ -32,7 +34,7 @@ Outputs:
The YAML shape includes:
```yaml
schema_version: weatherreporter.data_package.v2
schema_version: weatherreporter.data_package.v3
run_id: <run_id>
report:
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
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
use the same package schema and categories when converted into prompt input.
The default hourly module list places
@@ -101,8 +129,8 @@ Current categories are:
- This package owns prompt package schema, YAML marshaling, YAML loading, and
validation.
- It does not fetch weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke
Scriptorium.
choose module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium.
## Config Fields Used
@@ -142,5 +170,8 @@ Inspect:
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing 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
infer changes from rendered report text.

View File

@@ -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
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
workspace. Markdown-path reports then run `scriptorium run` directly to the
managed Markdown report path.
@@ -287,11 +287,12 @@ weatherreporter inspect sources RUN_ID
Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot, and
`inspect data-package` to review the structured prompt package used for
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
Recent Changes, or `null` when none exists. Use `inspect sources` to review
source provenance and warnings without dumping full weather payloads.
`inspect modules` to review the persisted ordered module snapshot with rich
template-facing values, and `inspect data-package` to review the curated prompt
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
snapshot selected for Recent Changes, or `null` when none exists. Use
`inspect sources` to review source provenance and warnings without dumping full
weather payloads.
## Recent Changes

View File

@@ -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
LLM prompt inputs concise, readable, and free of template-only helper fields.
This feature is not implemented yet. Current data-package behavior remains
documented outside `docs/roadmap/`.
This feature is implemented. Current data-package behavior is documented in
`docs/internal/prompt-input.md`; the rich module and template boundary is
documented in `docs/internal/module.md` and `docs/templates.md`.
## Problem

View File

@@ -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
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 |
| --- | --- | --- |
| `.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.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
| Variable | Type | Description |

View File

@@ -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"`) {
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)
if err != nil {
t.Fatalf("read data package: %v", err)
@@ -169,7 +179,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
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), "applicable_risk_products:") ||
!strings.Contains(string(data), "derived_summaries:") ||
@@ -222,6 +232,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || current["condition_text"] != "Clear" {
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)
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"])
@@ -230,6 +245,35 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
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"])
}
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)
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"])
@@ -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) {
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
@@ -2611,6 +2729,98 @@ func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
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) {
t.Helper()
for name, value := range values {

View File

@@ -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) {
periods := []weatherdata.ForecastPeriod{
{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) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -563,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
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 {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {

View File

@@ -23,6 +23,21 @@ type CurrentConditionsModule struct {
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) {
current := ctx.Collected.Current
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
}
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 {
return v.ConditionText == "" &&
v.ConditionTextLower == "" &&

View File

@@ -44,6 +44,34 @@ type DerivedDaypartSummaryModule struct {
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) {
if len(ctx.Derived.DaypartSummaries) == 0 {
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
}
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 {
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{

View File

@@ -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) {
tests := []struct {
name string

View File

@@ -20,6 +20,15 @@ type HourlyForecastModule struct {
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 {
HourLabel string `json:"hour_label,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
@@ -59,6 +68,42 @@ type HourlyForecastPeriod struct {
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) {
hourly := ctx.Collected.Hourly
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
}
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 {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
}

View File

@@ -21,6 +21,8 @@ type ModuleContext struct {
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
type ModulePromptExporter func(value any) (any, error)
type ModuleDefinition struct {
ID module.ID
StanzaName string
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
MissingData module.MissingDataBehavior
AllowDuplicate bool
Builder ModuleBuilder
PromptExporter ModulePromptExporter
}
type ModuleRegistry struct {
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) {
return NewModuleRegistry(defaultModuleDefinitions())
}
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
if 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
}
@@ -269,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
PromptExporter: exportCurrentConditionsPromptValue,
},
{
ID: module.NarrativeForecast,
@@ -289,6 +306,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
PromptExporter: exportHourlyForecastPromptValue,
},
{
ID: module.DerivedDailySummary,
@@ -307,6 +325,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
PromptExporter: exportDerivedDaypartSummariesPromptValue,
},
{
ID: module.PrecipTiming,

View File

@@ -1,6 +1,8 @@
package briefing
import (
"encoding/json"
"errors"
"strings"
"testing"
@@ -40,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
}
if output != nil {
if output.DataPackageValue() == nil {
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
}
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) {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
var found bool
@@ -250,3 +459,15 @@ func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
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),
},
}
}

View File

@@ -540,7 +540,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily") {
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))
}
if !strings.Contains(string(data), "location:") ||

View File

@@ -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 {
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 {
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 {
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 {
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" {
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 {
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" {
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 {
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" {
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 {
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
}

View File

@@ -38,6 +38,7 @@ type Output struct {
ID ID `json:"id"`
StanzaName string `json:"stanzaName"`
Value any `json:"value"`
PromptValue any `json:"-" yaml:"-"`
}
type Snapshot struct {
@@ -45,6 +46,13 @@ type Snapshot struct {
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) {
snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion,

View File

@@ -13,7 +13,12 @@ type testStanza struct {
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
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}},
})
if err != nil {
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
if 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) {
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
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 {
t.Fatalf("NewSnapshot() error = %v", err)

View File

@@ -16,7 +16,7 @@ import (
"gopkg.in/yaml.v3"
)
const SchemaVersion = "weatherreporter.data_package.v2"
const SchemaVersion = "weatherreporter.data_package.v3"
const (
metadataStanza = "metadata"
@@ -133,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value
values[output.StanzaName] = output.DataPackageValue()
}
return BriefingStanzas{Order: order, Values: values}
}

View File

@@ -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) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
@@ -143,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
if 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), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") ||
@@ -251,7 +289,7 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(`
schema_version: weatherreporter.data_package.v2
schema_version: weatherreporter.data_package.v3
run_id: 20260529T100000Z_daily
report:
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 {
t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)

View File

@@ -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) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{

View File

@@ -114,7 +114,12 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
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 {
t.Fatalf("NewSnapshot() error = %v", err)
}
@@ -220,6 +225,16 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
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)
if err != nil {
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" {
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
data, err := os.ReadFile(metadataPath)
if err != nil {