diff --git a/internal/app/app.go b/internal/app/app.go index 8049bcc..1d03da6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0aa39d4..4f8912f 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) diff --git a/internal/generatedtext/tomorrow.go b/internal/generatedtext/tomorrow.go new file mode 100644 index 0000000..1f60f3d --- /dev/null +++ b/internal/generatedtext/tomorrow.go @@ -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 +} diff --git a/internal/generatedtext/tomorrow_test.go b/internal/generatedtext/tomorrow_test.go new file mode 100644 index 0000000..ce54354 --- /dev/null +++ b/internal/generatedtext/tomorrow_test.go @@ -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) + } + }) + } +} diff --git a/internal/reporttemplate/prompts/tomorrow.generated_text.md b/internal/reporttemplate/prompts/tomorrow.generated_text.md new file mode 100644 index 0000000..3288273 --- /dev/null +++ b/internal/reporttemplate/prompts/tomorrow.generated_text.md @@ -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. diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index 64ed12c..94cfb76 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -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) { diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 61ce2fe..07d7ad6 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -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) { diff --git a/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json b/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json new file mode 100644 index 0000000..563b851 --- /dev/null +++ b/internal/reporttemplate/schemas/tomorrow.generated_text.schema.json @@ -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" + } + } +}