Add Tomorrow generated text contract
This commit is contained in:
@@ -678,7 +678,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
||||
}
|
||||
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
|
||||
generatedText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
||||
}
|
||||
@@ -692,7 +692,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, hourlyText)
|
||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, generatedText)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||
}
|
||||
@@ -1110,18 +1110,24 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
||||
}
|
||||
}
|
||||
|
||||
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) {
|
||||
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 generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
||||
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, hourly generatedtext.Hourly) (any, error) {
|
||||
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)
|
||||
default:
|
||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"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/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -795,6 +796,54 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *te
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
tomorrowDefinition.GeneratedTextSchemaID = "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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
|
||||
62
internal/generatedtext/tomorrow.go
Normal file
62
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Tomorrow struct {
|
||||
Summary string `json:"summary"`
|
||||
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var value Tomorrow
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if err != io.EOF {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||
}
|
||||
} else {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
|
||||
}
|
||||
|
||||
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 Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
|
||||
}
|
||||
if len(value.ForecastDiscussion) == 0 {
|
||||
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
|
||||
}
|
||||
|
||||
normalized, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err)
|
||||
}
|
||||
return value, normalized, nil
|
||||
}
|
||||
|
||||
func trimNonEmpty(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
111
internal/generatedtext/tomorrow_test.go
Normal file
111
internal/generatedtext/tomorrow_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
|
||||
value, normalized, err := ValidateTomorrow([]byte(`{
|
||||
"summary": " Storms become more likely tomorrow. ",
|
||||
"forecast_discussion": [
|
||||
" A front will keep showers in the forecast. ",
|
||||
"",
|
||||
" Temperatures stay seasonable by afternoon. "
|
||||
],
|
||||
"precipitation_timing": " Rain is most likely before sunrise. ",
|
||||
"confidence": " Medium "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||
}
|
||||
if value.Summary != "Storms become more likely tomorrow." {
|
||||
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||
}
|
||||
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers 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 before sunrise." {
|
||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||
}
|
||||
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
|
||||
if string(normalized) != want {
|
||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
|
||||
_, normalized, err := ValidateTomorrow([]byte(`{
|
||||
"summary": "Storms become more likely tomorrow.",
|
||||
"forecast_discussion": ["A front will keep showers in the forecast."],
|
||||
"precipitation_timing": " ",
|
||||
"confidence": " "
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||
}
|
||||
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
|
||||
if string(normalized) != want {
|
||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "malformed",
|
||||
in: `{`,
|
||||
want: "decode tomorrow generated text",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
|
||||
want: `unknown field "extra"`,
|
||||
},
|
||||
{
|
||||
name: "missing summary",
|
||||
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||
want: "summary is required",
|
||||
},
|
||||
{
|
||||
name: "blank summary",
|
||||
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||
want: "summary is required",
|
||||
},
|
||||
{
|
||||
name: "missing forecast discussion",
|
||||
in: `{"summary":"Storms become more likely tomorrow."}`,
|
||||
want: "forecast discussion is required",
|
||||
},
|
||||
{
|
||||
name: "blank forecast discussion",
|
||||
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
|
||||
want: "forecast discussion is required",
|
||||
},
|
||||
{
|
||||
name: "forecast discussion wrong type",
|
||||
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
|
||||
want: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, err := ValidateTomorrow([]byte(test.in))
|
||||
if err == nil {
|
||||
t.Fatal("ValidateTomorrow() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
18
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
18
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tomorrow Generated Text
|
||||
|
||||
Create structured prose for the Tomorrow Report using the supplied
|
||||
weatherreporter data package.
|
||||
|
||||
Return JSON that matches the Scriptorium-registered schema for
|
||||
`weather.tomorrow_generated_text`.
|
||||
|
||||
Fields:
|
||||
|
||||
- `summary`: concise overview of tomorrow's weather.
|
||||
- `forecast_discussion`: one or more paragraph strings explaining the main
|
||||
weather drivers and forecast reasoning.
|
||||
- `precipitation_timing`: optional plain-language precipitation timing context.
|
||||
- `confidence`: optional uncertainty or confidence note.
|
||||
|
||||
Use deterministic facts from the data package for weather details. Do not
|
||||
invent watches, warnings, precipitation windows, temperatures, or timing.
|
||||
@@ -16,7 +16,8 @@ var templates = map[string]string{
|
||||
}
|
||||
|
||||
var schemas = map[string]string{
|
||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||
}
|
||||
|
||||
func Template(id string) (string, error) {
|
||||
|
||||
@@ -23,6 +23,61 @@ func TestSchemaLookup(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Schema() error = %v", err)
|
||||
}
|
||||
assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"})
|
||||
}
|
||||
|
||||
func TestTomorrowSchemaLookup(t *testing.T) {
|
||||
data, err := Schema("tomorrow")
|
||||
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 assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
|
||||
t.Helper()
|
||||
schema := assertSchema(t, data, required)
|
||||
for _, field := range fields {
|
||||
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 assertSchema(t *testing.T, data []byte, required string) struct {
|
||||
Type string `json:"type"`
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
} {
|
||||
t.Helper()
|
||||
var schema struct {
|
||||
Type string `json:"type"`
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
@@ -38,18 +93,10 @@ func TestSchemaLookup(t *testing.T) {
|
||||
if schema.AdditionalProperties {
|
||||
t.Fatal("additionalProperties = true, want false")
|
||||
}
|
||||
if strings.Join(schema.Required, ",") != "summary,forecast_discussion" {
|
||||
t.Fatalf("required = %#v, want summary/forecast_discussion", schema.Required)
|
||||
}
|
||||
for _, field := range []string{"summary", "forecast_discussion", "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"])
|
||||
}
|
||||
if strings.Join(schema.Required, ",") != required {
|
||||
t.Fatalf("required = %#v, want %s", schema.Required, required)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func TestRenderHourly(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "weatherreporter.tomorrow.generated_text.schema.json",
|
||||
"title": "Tomorrow 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user