diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index 936bbc7..c6f00e7 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -9,20 +9,21 @@ This document describes structured generated-text handling in generated-text-template reports and builds curated render contexts for templates. It also owns the generated-text catalog that connects report definitions to validators, render-context builders, schema assets, and template -assets. The implemented contracts are Today Report, Tomorrow Report, and Hourly -Report. +assets. The implemented contracts are Daily validation and asset lookup, Today +Report, Tomorrow Report, and Hourly Report. ## Inputs And Outputs Inputs: -- raw GeneratedText JSON for Today, Tomorrow Report, or Hourly Report +- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report - report metadata from `internal/briefing` - a module snapshot from `internal/module` - validated generated text Outputs: +- typed `Daily` generated text - typed `Today` generated text - typed `Tomorrow` generated text - typed `Hourly` generated text @@ -33,6 +34,20 @@ Outputs: - generated-text catalog handlers for report definitions that use `generated_text_template` +The Daily generated text JSON accepts the same public fields and validation +rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and +template ID `daily`; render-context construction is not implemented until the +Daily report context exists. + +```json +{ + "summary": "string", + "forecast_discussion": ["string"], + "precipitation_timing": "string", + "confidence": "string" +} +``` + The hourly generated text JSON accepts: ```json @@ -96,8 +111,8 @@ JSON when blank. - Malformed generated-text JSON fails with decode context. - Unknown generated-text JSON fields fail during decoding. - Empty required fields fail after trimming whitespace. -- Today and Tomorrow forecast discussion fails when no nonblank paragraphs - remain. +- Daily, Today, and Tomorrow forecast discussion fails when no nonblank + paragraphs remain. - Missing optional render-context stanzas become nil module pointers. - Invalid render metadata, including missing timezone, missing generated time, or invalid valid period, fails before template rendering. @@ -109,6 +124,7 @@ JSON when blank. Inspect: - `internal/generatedtext/hourly_test.go` +- `internal/generatedtext/daily_test.go` - `internal/generatedtext/today_test.go` - `internal/generatedtext/tomorrow_test.go` - `internal/generatedtext/catalog_test.go` diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index afd5406..27a013d 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -6,8 +6,8 @@ in `internal/reporttemplate`. ## Purpose `internal/reporttemplate` owns repository-native report templates and companion -GeneratedText JSON schemas. The implemented template contracts are Today -Report, Tomorrow Report, and Hourly Report. +GeneratedText JSON schemas. The implemented template assets are Daily, Today, +Tomorrow, and Hourly. The package embeds assets from: @@ -31,13 +31,13 @@ Outputs: - GeneratedText schema bytes for prompt/schema configuration - rendered Markdown bytes for app orchestration to persist -The implemented template IDs are `today`, `tomorrow`, and `hourly`. The -implemented schema IDs are also `today`, `tomorrow`, and `hourly`, backed by -matching `*.generated_text.schema.json` files. +The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`. +The implemented schema IDs are also `daily`, `today`, `tomorrow`, and +`hourly`, backed by matching `*.generated_text.schema.json` files. -The Today generated-text prompt source is -`internal/reporttemplate/prompts/today.generated_text.md`, selected by prompt -ID `weather.today_generated_text`. +Generated-text prompt sources are maintained under +`internal/reporttemplate/prompts/`, including Daily's +`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`. ## Boundaries @@ -67,6 +67,10 @@ forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph forecast discussion generated-text slot. The ordered daypart slice is built in Go so templates do not range over maps. +The Daily template asset uses the same Markdown structure and field surface as +Tomorrow's template. Its typed render context in `internal/generatedtext` is +not implemented yet. + Templates use `text/template` with `missingkey=error`, so missing context fields fail rendering instead of producing incomplete Markdown. @@ -78,10 +82,11 @@ to write for each generated-text prompt. Hourly requires: - `summary` - `forecast_discussion` -Today and Tomorrow require `summary` and a nonempty `forecast_discussion` -array. All generated-text schemas allow optional `precipitation_timing` and -`confidence`, and reject additional properties. Weather truth remains in module -outputs; GeneratedText is limited to prose slots consumed by the template. +Daily, Today, and Tomorrow require `summary` and a nonempty +`forecast_discussion` array. All generated-text schemas allow optional +`precipitation_timing` and `confidence`, and reject additional properties. +Weather truth remains in module outputs; GeneratedText is limited to prose +slots consumed by the template. ## Failure Behavior diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index d3f9e45..5534bd6 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -12,10 +12,12 @@ import ( const ( schemaIDHourly = "hourly" + schemaIDDaily = "daily" schemaIDToday = "today" schemaIDTomorrow = "tomorrow" templateIDHourly = "hourly" + templateIDDaily = "daily" templateIDToday = "today" templateIDTomorrow = "tomorrow" ) @@ -46,6 +48,11 @@ var catalog = []catalogEntry{ validate: validateHourly, renderContextBuilder: buildHourlyContext, }, + { + schemaID: schemaIDDaily, + templateID: templateIDDaily, + validate: validateDaily, + }, { schemaID: schemaIDToday, templateID: templateIDToday, @@ -142,6 +149,10 @@ func validateHourly(data []byte) (any, []byte, error) { return ValidateHourly(data) } +func validateDaily(data []byte) (any, []byte, error) { + return ValidateDaily(data) +} + func validateToday(data []byte) (any, []byte, error) { return ValidateToday(data) } diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index 649d871..e5c9259 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -119,6 +119,32 @@ func TestCatalogLookupSupportsTodayDefinition(t *testing.T) { } } +func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) { + definition := report.Definition{ + ID: report.ID("daily"), + GenerationMode: report.GenerationModeGeneratedTextTemplate, + GeneratedTextSchemaID: "daily", + TemplateID: "daily", + } + handler, err := LookupDefinition(definition) + if err != nil { + t.Fatalf("LookupDefinition(daily) error = %v", err) + } + if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" { + t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID()) + } + if schema, err := handler.Schema(); err != nil { + t.Fatalf("Schema() error = %v", err) + } else if len(schema) == 0 { + t.Fatal("Schema() returned empty asset") + } + if template, err := handler.Template(); err != nil { + t.Fatalf("Template() error = %v", err) + } else if !strings.Contains(template, "# {{ .Report.Title }}") { + t.Fatalf("Template() = %q, want Daily template source", template) + } +} + func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly)) if err != nil { @@ -178,6 +204,29 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) { t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized) } + + dailyHandler, err := LookupDefinition(report.Definition{ + ID: report.ID("daily"), + GenerationMode: report.GenerationModeGeneratedTextTemplate, + GeneratedTextSchemaID: "daily", + TemplateID: "daily", + }) + if err != nil { + t.Fatalf("LookupDefinition(daily) error = %v", err) + } + daily, normalized, err := dailyHandler.Validate([]byte(`{ + "summary": " Showers are possible during the selected day. ", + "forecast_discussion": [" A front will keep rain chances in the forecast. ", ""] + }`)) + if err != nil { + t.Fatalf("Validate(daily) error = %v", err) + } + if _, ok := daily.(Daily); !ok { + t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily) + } + if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) { + t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized) + } } func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { @@ -231,3 +280,25 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err) } } + +func TestCatalogBuildRenderContextRejectsDailyUntilContextExists(t *testing.T) { + handler, err := LookupDefinition(report.Definition{ + ID: report.ID("daily"), + GenerationMode: report.GenerationModeGeneratedTextTemplate, + GeneratedTextSchemaID: "daily", + TemplateID: "daily", + }) + if err != nil { + t.Fatalf("LookupDefinition(daily) error = %v", err) + } + _, err = handler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Daily{ + Summary: "Showers are possible during the selected day.", + ForecastDiscussion: []string{"A front will keep rain chances in the forecast."}, + }) + if err == nil { + t.Fatal("BuildRenderContext(daily) error = nil, want missing builder") + } + if !strings.Contains(err.Error(), `render-context builder is not registered for template "daily"`) { + t.Fatalf("BuildRenderContext(daily) error = %v, want missing daily builder", err) + } +} diff --git a/internal/generatedtext/daily.go b/internal/generatedtext/daily.go new file mode 100644 index 0000000..9cdcc24 --- /dev/null +++ b/internal/generatedtext/daily.go @@ -0,0 +1,37 @@ +package generatedtext + +import ( + "fmt" + "strings" +) + +type Daily struct { + Summary string `json:"summary"` + ForecastDiscussion []string `json:"forecast_discussion"` + PrecipitationTiming string `json:"precipitation_timing,omitempty"` + Confidence string `json:"confidence,omitempty"` +} + +func ValidateDaily(data []byte) (Daily, []byte, error) { + value, err := decodeGeneratedText[Daily](data, "daily") + if err != nil { + return Daily{}, nil, err + } + + value.Summary = strings.TrimSpace(value.Summary) + value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) + value.Confidence = strings.TrimSpace(value.Confidence) + value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) + if value.Summary == "" { + return Daily{}, nil, fmt.Errorf("daily generated text summary is required") + } + if len(value.ForecastDiscussion) == 0 { + return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required") + } + + normalized, err := normalizeGeneratedText(value, "daily") + if err != nil { + return Daily{}, nil, err + } + return value, normalized, nil +} diff --git a/internal/generatedtext/daily_test.go b/internal/generatedtext/daily_test.go new file mode 100644 index 0000000..d7194ce --- /dev/null +++ b/internal/generatedtext/daily_test.go @@ -0,0 +1,111 @@ +package generatedtext + +import ( + "strings" + "testing" +) + +func TestValidateDailyNormalizesJSON(t *testing.T) { + value, normalized, err := ValidateDaily([]byte(`{ + "summary": " Showers are possible during the selected day. ", + "forecast_discussion": [ + " A front will keep rain chances in the forecast. ", + "", + " Temperatures stay seasonable by afternoon. " + ], + "precipitation_timing": " Rain is most likely during the afternoon. ", + "confidence": " Medium " + }`)) + if err != nil { + t.Fatalf("ValidateDaily() error = %v", err) + } + if value.Summary != "Showers are possible during the selected day." { + t.Fatalf("Summary = %q, want trimmed summary", value.Summary) + } + if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." { + t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion) + } + if value.PrecipitationTiming != "Rain is most likely during the afternoon." { + t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) + } + want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } +} + +func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) { + _, normalized, err := ValidateDaily([]byte(`{ + "summary": "Showers are possible during the selected day.", + "forecast_discussion": ["A front will keep rain chances in the forecast."], + "precipitation_timing": " ", + "confidence": " " + }`)) + if err != nil { + t.Fatalf("ValidateDaily() error = %v", err) + } + want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } +} + +func TestValidateDailyRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "malformed", + in: `{`, + want: "decode daily generated text", + }, + { + name: "unknown field", + in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`, + want: `unknown field "extra"`, + }, + { + name: "missing summary", + in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`, + want: "summary is required", + }, + { + name: "blank summary", + in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`, + want: "summary is required", + }, + { + name: "missing forecast discussion", + in: `{"summary":"Showers are possible during the selected day."}`, + want: "forecast discussion is required", + }, + { + name: "blank forecast discussion", + in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`, + want: "forecast discussion is required", + }, + { + name: "forecast discussion wrong type", + in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`, + want: "cannot unmarshal string", + }, + { + name: "multiple values", + in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`, + want: "multiple JSON values", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, _, err := ValidateDaily([]byte(test.in)) + if err == nil { + t.Fatal("ValidateDaily() error = nil, want error") + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/internal/reporttemplate/prompts/daily.generated_text.md b/internal/reporttemplate/prompts/daily.generated_text.md new file mode 100644 index 0000000..2a6e9ac --- /dev/null +++ b/internal/reporttemplate/prompts/daily.generated_text.md @@ -0,0 +1,38 @@ +TASK: You are writing structured prose slots for a dated daily weather report. + +The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema. + +Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data. + +The report focuses on the selected local civil day in `report.valid_period` for the configured location. + +Return these fields: + +- `summary`: required. 1-2 sentences summarizing the main weather story for the selected day. +- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to the selected day. +- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows. +- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast. + +Return JSON only. + +# summary + +Lead with the most practical local outcome for the selected day. If an active warning is relevant during the report period, lead with the hazard. + +Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package. + +# forecast_discussion + +Use deterministic module facts and narrative products to explain the most useful details for the selected day. + +Useful context may include: + +- timing of condition changes by daypart or hour +- boundaries, forcing, moisture, instability, or storm mode when supported +- active alerts or SPC outlooks that apply to the location +- planning concerns surfaced by `daily_planning` +- confidence or uncertainty + +# precipitation_timing + +Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported. diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index 5761017..ea36929 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -12,12 +12,14 @@ import ( var assets embed.FS var templates = map[string]string{ + "daily": "templates/daily.md.tmpl", "hourly": "templates/hourly.md.tmpl", "today": "templates/today.md.tmpl", "tomorrow": "templates/tomorrow.md.tmpl", } var schemas = map[string]string{ + "daily": "schemas/daily.generated_text.schema.json", "hourly": "schemas/hourly.generated_text.schema.json", "today": "schemas/today.generated_text.schema.json", "tomorrow": "schemas/tomorrow.generated_text.schema.json", diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index c12a674..b656955 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -2,6 +2,7 @@ package reporttemplate import ( "encoding/json" + "os" "strings" "testing" ) @@ -30,6 +31,18 @@ func TestTomorrowTemplateLookup(t *testing.T) { } } +func TestDailyTemplateLookup(t *testing.T) { + source, err := Template("daily") + if err != nil { + t.Fatalf("Template() error = %v", err) + } + for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { + if !strings.Contains(source, want) { + t.Fatalf("template missing %q:\n%s", want, source) + } + } +} + func TestTodayTemplateLookup(t *testing.T) { source, err := Template("today") if err != nil { @@ -84,6 +97,37 @@ func TestTomorrowSchemaLookup(t *testing.T) { } } +func TestDailySchemaLookup(t *testing.T) { + data, err := Schema("daily") + if err != nil { + t.Fatalf("Schema() error = %v", err) + } + schema := assertSchema(t, data, "summary,forecast_discussion") + property, ok := schema.Properties["forecast_discussion"].(map[string]any) + if !ok { + t.Fatal("schema property forecast_discussion missing or invalid") + } + if property["type"] != "array" { + t.Fatalf("forecast_discussion type = %v, want array", property["type"]) + } + if property["minItems"] != float64(1) { + t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) + } + items, ok := property["items"].(map[string]any) + if !ok || items["type"] != "string" { + t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) + } + for _, field := range []string{"summary", "precipitation_timing", "confidence"} { + property, ok := schema.Properties[field].(map[string]any) + if !ok { + t.Fatalf("schema property %q missing or invalid", field) + } + if property["type"] != "string" { + t.Fatalf("schema property %q type = %v, want string", field, property["type"]) + } + } +} + func TestTodaySchemaLookup(t *testing.T) { data, err := Schema("today") if err != nil { @@ -351,6 +395,85 @@ func TestRenderTomorrow(t *testing.T) { }) } +func TestRenderDaily(t *testing.T) { + rendered, err := Render("daily", testDailyRenderContext{ + Report: testDailyReportContext{ + Title: "Monday's Weather", + ForecastDateLabel: "Monday, June 15, 2026", + GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", + }, + GeneratedText: testDailyGeneratedText{ + Summary: "The selected day starts dry before showers return later in the day.", + ForecastDiscussion: []string{ + "Clouds increase after sunrise.", + "Rain chances peak during the afternoon.", + }, + PrecipitationTiming: "A few showers may linger into early evening.", + }, + Modules: testDailyModules{ + Dayparts: []testDailyDaypart{ + { + Key: "morning", + Summary: testDaypartSummary{ + DisplayName: "Morning", + TemperatureTrend: "rising", + TemperatureStartPhraseF: "upper 50s", + TemperatureEndPhraseF: "upper 60s", + DominantConditionDisplay: "Sunny", + DominantConditionLower: "sunny", + }, + }, + { + Key: "afternoon", + Summary: testDaypartSummary{ + DisplayName: "Afternoon", + TemperatureTrend: "steady", + TemperatureSteadyPhraseF: "upper 70s", + DominantConditionDisplay: "Showers", + DominantConditionLower: "showers", + MaxPopPercent: intPtr(70), + MaxPopTimeLabel: "3:00 PM", + MentionPrecipitation: true, + }, + }, + }, + PrecipTiming: &testPrecipTiming{ + PrecipitationWindows: []testPrecipWindow{ + {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "# Monday's Weather", + "**Forecast date:** Monday, June 15, 2026", + "**Updated:** Sunday, June 14, 2026 at 9:14 AM", + "The selected day starts dry before showers return later in the day.", + "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", + "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", + "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", + "A few showers may linger into early evening.", + "Clouds increase after sunrise.", + "Rain chances peak during the afternoon.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } + assertOrderedText(t, text, []string{ + "# Monday's Weather", + "## Daypart Forecast", + "- **Morning:**", + "- **Afternoon:**", + "## Precipitation Timing", + "## Forecast Discussion", + }) +} + func TestRenderToday(t *testing.T) { rendered, err := Render("today", testTodayRenderContext{ Report: testTodayReportContext{ @@ -532,17 +655,29 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { } func TestUnknownAssetsReturnActionableErrors(t *testing.T) { - if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { + if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) { t.Fatalf("Template() error = %v, want unknown template", err) } - if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) { + if _, err := Schema("missing"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "missing"`) { t.Fatalf("Schema() error = %v, want unknown schema", err) } - if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { + if _, err := Render("missing", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) { t.Fatalf("Render() error = %v, want unknown template", err) } } +func TestDailyPromptAssetExists(t *testing.T) { + data, err := os.ReadFile("prompts/daily.generated_text.md") + if err != nil { + t.Fatalf("read Daily prompt asset: %v", err) + } + for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} { + if !strings.Contains(string(data), want) { + t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data)) + } + } +} + func TestRenderFailsForMissingContextFields(t *testing.T) { _, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}}) if err == nil { @@ -571,6 +706,12 @@ type testTodayRenderContext struct { Modules testTodayModules } +type testDailyRenderContext struct { + Report testDailyReportContext + GeneratedText testDailyGeneratedText + Modules testDailyModules +} + type testReportContext struct { Title string LocationName string @@ -597,6 +738,12 @@ type testTodayReportContext struct { GeneratedAtLabel string } +type testDailyReportContext struct { + Title string + ForecastDateLabel string + GeneratedAtLabel string +} + type testTomorrowGeneratedText struct { Summary string ForecastDiscussion []string @@ -604,6 +751,13 @@ type testTomorrowGeneratedText struct { Confidence string } +type testDailyGeneratedText struct { + Summary string + ForecastDiscussion []string + PrecipitationTiming string + Confidence string +} + type testModules struct { CurrentConditions *testCurrentConditions HourlyForecast *testHourlyForecast @@ -627,6 +781,11 @@ type testTodayModules struct { TodayPlanning *testTodayPlanning } +type testDailyModules struct { + Dayparts []testDailyDaypart + PrecipTiming *testPrecipTiming +} + type testTodayPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string @@ -639,6 +798,11 @@ type testTomorrowDaypart struct { Summary testDaypartSummary } +type testDailyDaypart struct { + Key string + Summary testDaypartSummary +} + type testDaypartSummary struct { DisplayName string TempRangeF string diff --git a/internal/reporttemplate/schemas/daily.generated_text.schema.json b/internal/reporttemplate/schemas/daily.generated_text.schema.json new file mode 100644 index 0000000..a8f4bc9 --- /dev/null +++ b/internal/reporttemplate/schemas/daily.generated_text.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "weatherreporter.daily.generated_text.schema.json", + "title": "Daily GeneratedText", + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "forecast_discussion" + ], + "properties": { + "summary": { + "type": "string" + }, + "forecast_discussion": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "precipitation_timing": { + "type": "string" + }, + "confidence": { + "type": "string" + } + } +} diff --git a/internal/reporttemplate/templates/daily.md.tmpl b/internal/reporttemplate/templates/daily.md.tmpl new file mode 100644 index 0000000..43bed95 --- /dev/null +++ b/internal/reporttemplate/templates/daily.md.tmpl @@ -0,0 +1,24 @@ +# {{ .Report.Title }} + +**Forecast date:** {{ .Report.ForecastDateLabel }} +**Updated:** {{ .Report.GeneratedAtLabel }} + +{{ .GeneratedText.Summary }} + +## Daypart Forecast +{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} +{{ end }}{{ else }}- No daypart forecast details are available. +{{ end }} + +{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} +## Precipitation Timing +{{ range . }}{{ $window := . }} +- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }} +{{ end }}{{ with $.GeneratedText.PrecipitationTiming }} +{{ . }} +{{ end }} +{{ end }}{{ end }} +## Forecast Discussion +{{ range .GeneratedText.ForecastDiscussion }} +{{ . }} +{{ end }}