diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go new file mode 100644 index 0000000..64ed12c --- /dev/null +++ b/internal/reporttemplate/reporttemplate.go @@ -0,0 +1,60 @@ +// Package reporttemplate provides embedded Markdown templates and schemas. +package reporttemplate + +import ( + "bytes" + "embed" + "fmt" + "text/template" +) + +//go:embed templates/*.md.tmpl schemas/*.schema.json +var assets embed.FS + +var templates = map[string]string{ + "hourly": "templates/hourly.md.tmpl", +} + +var schemas = map[string]string{ + "hourly": "schemas/hourly.generated_text.schema.json", +} + +func Template(id string) (string, error) { + path, ok := templates[id] + if !ok { + return "", fmt.Errorf("unknown report template %q", id) + } + data, err := assets.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read report template %q: %w", id, err) + } + return string(data), nil +} + +func Schema(id string) ([]byte, error) { + path, ok := schemas[id] + if !ok { + return nil, fmt.Errorf("unknown generated text schema %q", id) + } + data, err := assets.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read generated text schema %q: %w", id, err) + } + return append([]byte(nil), data...), nil +} + +func Render(id string, data any) ([]byte, error) { + source, err := Template(id) + if err != nil { + return nil, err + } + tmpl, err := template.New(id).Option("missingkey=error").Parse(source) + if err != nil { + return nil, fmt.Errorf("parse report template %q: %w", id, err) + } + var out bytes.Buffer + if err := tmpl.Execute(&out, data); err != nil { + return nil, fmt.Errorf("render report template %q: %w", id, err) + } + return out.Bytes(), nil +} diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go new file mode 100644 index 0000000..d46aa05 --- /dev/null +++ b/internal/reporttemplate/reporttemplate_test.go @@ -0,0 +1,157 @@ +package reporttemplate + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTemplateLookup(t *testing.T) { + source, err := Template("hourly") + if err != nil { + t.Fatalf("Template() error = %v", err) + } + for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} { + if !strings.Contains(source, want) { + t.Fatalf("template missing %q:\n%s", want, source) + } + } +} + +func TestSchemaLookup(t *testing.T) { + data, err := Schema("hourly") + if err != nil { + t.Fatalf("Schema() error = %v", err) + } + var schema struct { + Type string `json:"type"` + AdditionalProperties bool `json:"additionalProperties"` + Required []string `json:"required"` + Properties map[string]any `json:"properties"` + } + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatalf("schema is invalid JSON: %v", err) + } + if schema.Type != "object" { + t.Fatalf("schema type = %q, want object", schema.Type) + } + if schema.AdditionalProperties { + t.Fatal("additionalProperties = true, want false") + } + if strings.Join(schema.Required, ",") != "summary,timing,impacts" { + t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required) + } + for _, field := range []string{"summary", "timing", "impacts", "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 TestRenderHourly(t *testing.T) { + rendered, err := Render("hourly", testRenderContext{ + ReportTitle: "Hourly Report", + LocationName: "Brentwood", + ValidPeriod: "May 29, 8:30 AM to 2:30 PM", + GeneratedAt: "May 29, 8:30 AM", + CurrentConditions: "74 F, light south wind.", + PrecipitationTiming: "Showers are most likely late morning.", + WeatherStory: "Morning storms remain the main story.", + GeneratedText: testGeneratedText{ + Summary: "Storm chances increase through late morning.", + Timing: "The main window is 10 AM to noon.", + Impacts: "Brief downpours may slow travel.", + Confidence: "Medium confidence in timing.", + }, + HourlyForecast: []testHourlyRow{ + {Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"}, + {Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"}, + }, + Alerts: []string{"Flood Watch until 2:30 PM"}, + SPCOutlooks: []string{"Slight Risk through afternoon"}, + SPCDiscussions: []string{"Strong storms may develop late morning."}, + ForecastDiscussion: testForecastDiscussion{ + KeyMessages: []string{"Storms are most likely late morning."}, + ShortTerm: "Short-term discussion favors increasing rain coverage.", + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "# Hourly Report", + "Valid: May 29, 8:30 AM to 2:30 PM", + "Storm chances increase through late morning.", + "## Confidence", + "- 10 AM: Showers; 75 F; 70% showers; S 10 mph", + "- Flood Watch until 2:30 PM", + "Short-term discussion favors increasing rain coverage.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } +} + +func TestUnknownAssetsReturnActionableErrors(t *testing.T) { + if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { + t.Fatalf("Template() error = %v, want unknown template", err) + } + if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) { + t.Fatalf("Schema() error = %v, want unknown schema", err) + } + if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { + t.Fatalf("Render() error = %v, want unknown template", err) + } +} + +func TestRenderFailsForMissingContextFields(t *testing.T) { + _, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"}) + if err == nil { + t.Fatal("Render() error = nil, want missing field error") + } + if !strings.Contains(err.Error(), `render report template "hourly"`) { + t.Fatalf("Render() error = %v, want render context", err) + } +} + +type testRenderContext struct { + ReportTitle string + LocationName string + ValidPeriod string + GeneratedAt string + CurrentConditions string + PrecipitationTiming string + WeatherStory string + GeneratedText testGeneratedText + HourlyForecast []testHourlyRow + Alerts []string + SPCOutlooks []string + SPCDiscussions []string + ForecastDiscussion testForecastDiscussion +} + +type testGeneratedText struct { + Summary string + Timing string + Impacts string + Confidence string +} + +type testHourlyRow struct { + Time string + Summary string + Temperature string + Precipitation string + Wind string +} + +type testForecastDiscussion struct { + KeyMessages []string + ShortTerm string +} diff --git a/internal/reporttemplate/schemas/hourly.generated_text.schema.json b/internal/reporttemplate/schemas/hourly.generated_text.schema.json new file mode 100644 index 0000000..07827e9 --- /dev/null +++ b/internal/reporttemplate/schemas/hourly.generated_text.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "weatherreporter.hourly.generated_text.schema.json", + "title": "Hourly GeneratedText", + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "timing", + "impacts" + ], + "properties": { + "summary": { + "type": "string" + }, + "timing": { + "type": "string" + }, + "impacts": { + "type": "string" + }, + "confidence": { + "type": "string" + } + } +} diff --git a/internal/reporttemplate/templates/hourly.md.tmpl b/internal/reporttemplate/templates/hourly.md.tmpl new file mode 100644 index 0000000..94a115b --- /dev/null +++ b/internal/reporttemplate/templates/hourly.md.tmpl @@ -0,0 +1,71 @@ +# {{ .ReportTitle }} + +{{ .LocationName }} +Valid: {{ .ValidPeriod }} +Generated: {{ .GeneratedAt }} + +## Summary + +{{ .GeneratedText.Summary }} + +## Timing + +{{ .GeneratedText.Timing }} + +## Impacts + +{{ .GeneratedText.Impacts }} +{{ with .GeneratedText.Confidence }} + +## Confidence + +{{ . }} +{{ end }} + +## Current Conditions + +{{ .CurrentConditions }} + +## Hourly Forecast +{{ range .HourlyForecast }} +- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }} +{{ else }} +- No hourly forecast rows available. +{{ end }} + +## Precipitation Timing + +{{ .PrecipitationTiming }} + +## Alerts +{{ range .Alerts }} +- {{ . }} +{{ else }} +- No active alert overlaps for this report period. +{{ end }} + +## SPC Outlooks +{{ range .SPCOutlooks }} +- {{ . }} +{{ else }} +- No overlapping SPC outlooks. +{{ end }} + +## Forecast Discussion +{{ range .ForecastDiscussion.KeyMessages }} +- {{ . }} +{{ end }}{{ with .ForecastDiscussion.ShortTerm }} + +{{ . }} +{{ end }} + +## SPC Discussion +{{ range .SPCDiscussions }} +- {{ . }} +{{ else }} +- No overlapping SPC discussion. +{{ end }} + +## Weather Story + +{{ .WeatherStory }}