Centralize generated text template catalog
This commit is contained in:
@@ -103,17 +103,19 @@ For `scriptorium_markdown` reports, generation then:
|
|||||||
|
|
||||||
For `generated_text_template` reports, generation then:
|
For `generated_text_template` reports, generation then:
|
||||||
|
|
||||||
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
12. Looks up the generated-text catalog entry for the report schema/template
|
||||||
13. Saves the structured Scriptorium run result.
|
IDs.
|
||||||
14. Validates and saves normalized generated text.
|
13. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||||
15. Builds and saves a typed render context.
|
14. Saves the structured Scriptorium run result.
|
||||||
16. Renders Markdown from the embedded template to the managed report path.
|
15. Validates and saves normalized generated text.
|
||||||
17. Saves final metadata with generated-text paths, render context path, schema
|
16. Builds and saves a typed render context.
|
||||||
|
17. Renders Markdown from the embedded template to the managed report path.
|
||||||
|
18. Saves final metadata with generated-text paths, render context path, schema
|
||||||
ID, and managed report path.
|
ID, and managed report path.
|
||||||
18. Copies the managed report to the requested `--out` path when provided.
|
19. Copies the managed report to the requested `--out` path when provided.
|
||||||
19. If distributor notification is enabled, notifies using the managed report
|
20. If distributor notification is enabled, notifies using the managed report
|
||||||
path as the source file.
|
path as the source file.
|
||||||
20. Saves a distributor notification debug artifact and updates metadata with
|
21. Saves a distributor notification debug artifact and updates metadata with
|
||||||
its path.
|
its path.
|
||||||
|
|
||||||
If render preflight returns both a result and an error, preflight JSON and
|
If render preflight returns both a result and an error, preflight JSON and
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ This document describes structured generated-text handling in
|
|||||||
|
|
||||||
`internal/generatedtext` validates structured text returned for
|
`internal/generatedtext` validates structured text returned for
|
||||||
generated-text-template reports and builds curated render contexts for
|
generated-text-template reports and builds curated render contexts for
|
||||||
templates. The implemented contracts are Tomorrow Report and Hourly Report.
|
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 Tomorrow Report and Hourly Report.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ Outputs:
|
|||||||
- normalized stable JSON for validated generated text
|
- normalized stable JSON for validated generated text
|
||||||
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
|
- generated-text catalog handlers for report definitions that use
|
||||||
|
`generated_text_template`
|
||||||
|
|
||||||
The hourly generated text JSON accepts:
|
The hourly generated text JSON accepts:
|
||||||
|
|
||||||
@@ -60,10 +64,12 @@ JSON when blank.
|
|||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns typed generated-text validation and render-context shaping.
|
- This package owns typed generated-text validation and render-context shaping.
|
||||||
|
- It owns generated-text catalog lookup for schema/template combinations.
|
||||||
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
||||||
- It does not invoke Scriptorium, write state artifacts, choose report
|
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||||
definitions, compare snapshots, or render templates directly in production
|
definitions, compare snapshots, or own embedded template/schema files.
|
||||||
workflows.
|
- It renders through `internal/reporttemplate`; embedded asset lookup remains
|
||||||
|
in `internal/reporttemplate`.
|
||||||
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
||||||
limited to typed JSON decoding, unknown-field rejection, and required-field
|
limited to typed JSON decoding, unknown-field rejection, and required-field
|
||||||
checks.
|
checks.
|
||||||
@@ -77,6 +83,8 @@ JSON when blank.
|
|||||||
- Missing optional render-context stanzas become nil module pointers.
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
- Invalid render metadata, including missing timezone, missing generated time,
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
or invalid valid period, fails before template rendering.
|
or invalid valid period, fails before template rendering.
|
||||||
|
- Unsupported generated-text schema IDs, template IDs, or schema/template
|
||||||
|
combinations fail during catalog lookup with report ID context.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -84,11 +92,14 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/generatedtext/hourly_test.go`
|
- `internal/generatedtext/hourly_test.go`
|
||||||
- `internal/generatedtext/tomorrow_test.go`
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
|
- `internal/generatedtext/catalog_test.go`
|
||||||
- `internal/generatedtext/render_context_test.go`
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Render contexts are curated structs, not raw prompt-input packages.
|
- Render contexts are curated structs, not raw prompt-input packages.
|
||||||
- Required generated text is normalized before downstream artifact storage.
|
- Required generated text is normalized before downstream artifact storage.
|
||||||
|
- Generated-text-template reports must have one catalog entry matching their
|
||||||
|
report definition schema and template IDs.
|
||||||
- Missing optional weather narrative stanzas produce empty or fallback render
|
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||||
context fields rather than forcing raw module data into templates.
|
context fields rather than forcing raw module data into templates.
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ validate GeneratedText, construct render contexts, choose report definitions,
|
|||||||
write artifacts, invoke Scriptorium, or notify distributor.
|
write artifacts, invoke Scriptorium, or notify distributor.
|
||||||
|
|
||||||
GeneratedText validation is owned by `internal/generatedtext`. App
|
GeneratedText validation is owned by `internal/generatedtext`. App
|
||||||
orchestration decides which template and schema IDs apply to a report through
|
orchestration uses `internal/generatedtext` catalog lookup to connect
|
||||||
`internal/report` definitions.
|
`internal/report` definition schema/template IDs to the matching validator,
|
||||||
|
render-context builder, and embedded assets.
|
||||||
|
|
||||||
## Template Contracts
|
## Template Contracts
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
@@ -654,6 +653,11 @@ type generatedReportRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
||||||
|
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
|
||||||
|
}
|
||||||
|
|
||||||
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
|
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
|
||||||
PromptID: req.Resolved.Definition.PromptID,
|
PromptID: req.Resolved.Definition.PromptID,
|
||||||
DataPackagePath: req.dataPackagePath,
|
DataPackagePath: req.dataPackagePath,
|
||||||
@@ -680,7 +684,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
||||||
}
|
}
|
||||||
generatedText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
|
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
||||||
}
|
}
|
||||||
@@ -694,7 +698,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, generatedText)
|
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||||
}
|
}
|
||||||
@@ -708,7 +712,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered, err := reporttemplate.Render(req.Resolved.Definition.TemplateID, renderContext)
|
rendered, err := handler.Render(renderContext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
|
||||||
}
|
}
|
||||||
@@ -1113,36 +1117,6 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGeneratedText(definition report.Definition, data []byte) (any, []byte, error) {
|
|
||||||
switch definition.GeneratedTextSchemaID {
|
|
||||||
case "hourly":
|
|
||||||
return generatedtext.ValidateHourly(data)
|
|
||||||
case "tomorrow":
|
|
||||||
return generatedtext.ValidateTomorrow(data)
|
|
||||||
default:
|
|
||||||
return nil, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, generated any) (any, error) {
|
|
||||||
switch definition.TemplateID {
|
|
||||||
case "hourly":
|
|
||||||
hourly, ok := generated.(generatedtext.Hourly)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", definition.TemplateID, definition.ID)
|
|
||||||
}
|
|
||||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
|
||||||
case "tomorrow":
|
|
||||||
tomorrow, ok := generated.(generatedtext.Tomorrow)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", definition.TemplateID, definition.ID)
|
|
||||||
}
|
|
||||||
return generatedtext.BuildTomorrowRenderContext(metadata, snapshot, tomorrow, reportFacts.Collected, reportFacts.Derived)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -820,7 +819,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) {
|
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *testing.T) {
|
||||||
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
||||||
resolved.Definition.TemplateID = "missing-template"
|
resolved.Definition.TemplateID = "missing-template"
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
@@ -837,77 +836,21 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *te
|
|||||||
Store: store,
|
Store: store,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
})
|
})
|
||||||
assertGeneratedReportError(t, err, resolved, "build render context")
|
assertGeneratedReportError(t, err, resolved, "lookup generated text catalog")
|
||||||
|
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
|
||||||
|
t.Fatalf("calls structured=%d run=%d, want no generated text run", renderer.structuredRunCalls, renderer.runCalls)
|
||||||
|
}
|
||||||
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
||||||
|
|
||||||
paths := hourlyArtifactPaths(t, store, resolved)
|
paths := hourlyArtifactPaths(t, store, resolved)
|
||||||
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
|
assertPathsExist(t, paths.Preflight, paths.Metadata)
|
||||||
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
|
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
|
||||||
metadataData, readErr := os.ReadFile(paths.Metadata)
|
metadataData, readErr := os.ReadFile(paths.Metadata)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
t.Fatalf("read metadata: %v", readErr)
|
t.Fatalf("read metadata: %v", readErr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(metadataData), paths.GeneratedText) {
|
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
|
||||||
t.Fatalf("metadata missing generated text link:\n%s", string(metadataData))
|
t.Fatalf("metadata missing generated text artifact path:\n%s", string(metadataData))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeneratedTextValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|
||||||
hourlyDefinition := report.DefaultRegistry().MustLookup(report.Hourly)
|
|
||||||
hourly, normalized, err := validateGeneratedText(hourlyDefinition, []byte(`{
|
|
||||||
"summary": " Storm chances increase. ",
|
|
||||||
"forecast_discussion": " A front will keep the region unsettled. "
|
|
||||||
}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("validateGeneratedText(hourly) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := hourly.(generatedtext.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
|
|
||||||
tomorrow, normalized, err := validateGeneratedText(tomorrowDefinition, []byte(`{
|
|
||||||
"summary": " Storms become more likely tomorrow. ",
|
|
||||||
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
|
|
||||||
}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("validateGeneratedText(tomorrow) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := tomorrow.(generatedtext.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 TestBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
|
||||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
|
||||||
|
|
||||||
_, err := buildRenderContext(definition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Tomorrow{
|
|
||||||
Summary: "Storms become more likely tomorrow.",
|
|
||||||
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("buildRenderContext() error = nil, want type mismatch")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `requires hourly generated text`) {
|
|
||||||
t.Fatalf("buildRenderContext() error = %v, want hourly generated text requirement", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow)
|
|
||||||
_, err = buildRenderContext(tomorrowDefinition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Hourly{
|
|
||||||
Summary: "Storm chances increase.",
|
|
||||||
ForecastDiscussion: "A front will keep showers in the forecast.",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("buildRenderContext() error = nil, want tomorrow type mismatch")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
|
||||||
t.Fatalf("buildRenderContext() error = %v, want tomorrow generated text requirement", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
155
internal/generatedtext/catalog.go
Normal file
155
internal/generatedtext/catalog.go
Normal 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)
|
||||||
|
}
|
||||||
164
internal/generatedtext/catalog_test.go
Normal file
164
internal/generatedtext/catalog_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user