Add embedded hourly report template assets
This commit is contained in:
60
internal/reporttemplate/reporttemplate.go
Normal file
60
internal/reporttemplate/reporttemplate.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
157
internal/reporttemplate/reporttemplate_test.go
Normal file
157
internal/reporttemplate/reporttemplate_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
@@ -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 }}
|
||||||
Reference in New Issue
Block a user