Centralize generated text template catalog

This commit is contained in:
2026-06-15 12:24:47 +00:00
parent ff2f8c16a3
commit a02af0bce0
7 changed files with 364 additions and 114 deletions

View File

@@ -0,0 +1,155 @@
package generatedtext
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
)
const (
schemaIDHourly = "hourly"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDTomorrow = "tomorrow"
)
type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
type catalogEntry struct {
schemaID string
templateID string
validate validator
buildRenderContext renderContextBuilder
}
type Handler struct {
reportID report.ID
schemaID string
templateID string
validate validator
buildRenderContext renderContextBuilder
}
var catalog = []catalogEntry{
{
schemaID: schemaIDHourly,
templateID: templateIDHourly,
validate: validateHourly,
buildRenderContext: buildHourlyContext,
},
{
schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow,
validate: validateTomorrow,
buildRenderContext: buildTomorrowContext,
},
}
func LookupDefinition(definition report.Definition) (Handler, error) {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
}
var schemaKnown, templateKnown bool
for _, entry := range catalog {
if entry.schemaID == definition.GeneratedTextSchemaID {
schemaKnown = true
}
if entry.templateID == definition.TemplateID {
templateKnown = true
}
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
return Handler{
reportID: definition.ID,
schemaID: entry.schemaID,
templateID: entry.templateID,
validate: entry.validate,
buildRenderContext: entry.buildRenderContext,
}, nil
}
}
if !schemaKnown {
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
if !templateKnown {
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
}
func (h Handler) SchemaID() string {
return h.schemaID
}
func (h Handler) TemplateID() string {
return h.templateID
}
func (h Handler) Schema() ([]byte, error) {
data, err := reporttemplate.Schema(h.schemaID)
if err != nil {
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
}
return data, nil
}
func (h Handler) Template() (string, error) {
source, err := reporttemplate.Template(h.templateID)
if err != nil {
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return source, nil
}
func (h Handler) Validate(data []byte) (any, []byte, error) {
if h.validate == nil {
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
}
return h.validate(data)
}
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
if h.buildRenderContext == nil {
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
}
return h.buildRenderContext(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
}
func (h Handler) Render(data any) ([]byte, error) {
rendered, err := reporttemplate.Render(h.templateID, data)
if err != nil {
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return rendered, nil
}
func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data)
}
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
hourly, ok := generated.(Hourly)
if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
}
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
}
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow)
if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
}
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
}

View File

@@ -0,0 +1,164 @@
package generatedtext
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
for _, definition := range report.DefaultRegistry().All() {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
continue
}
t.Run(string(definition.ID), func(t *testing.T) {
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
if handler.SchemaID() != definition.GeneratedTextSchemaID {
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
}
if handler.TemplateID() != definition.TemplateID {
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
}
if handler.validate == nil {
t.Fatal("validator is nil")
}
if handler.buildRenderContext == nil {
t.Fatal("render-context builder is nil")
}
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 template == "" {
t.Fatal("Template() returned empty asset")
}
})
}
}
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
tests := []struct {
name string
definition report.Definition
want []string
}{
{
name: "schema",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.GeneratedTextSchemaID = "missing-schema"
return definition
}(),
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
},
{
name: "template",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "missing-template"
return definition
}(),
want: []string{`report template "missing-template"`, `report "hourly"`},
},
{
name: "combination",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "tomorrow"
return definition
}(),
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LookupDefinition(test.definition)
if err == nil {
t.Fatal("LookupDefinition() error = nil, want error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
}
}
})
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. "
}`))
if err != nil {
t.Fatalf("Validate(hourly) error = %v", err)
}
if _, ok := hourly.(Hourly); !ok {
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
}
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(tomorrow) error = %v", err)
}
if _, ok := tomorrow.(Tomorrow); !ok {
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires hourly generated text`) {
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
if err == nil {
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
}
}