From 7884b9a6c34f02afc3ea6ac1b16613ebe7d9714c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 04:10:45 +0000 Subject: [PATCH] Retire dormant prompt compatibility APIs --- docs/internal/generatedtext.md | 4 +- docs/internal/prompt-input.md | 6 +- internal/app/profile_execution.go | 2 +- internal/generatedtext/catalog.go | 14 +-- internal/generatedtext/catalog_test.go | 24 ++--- internal/generatedtext/daily.go | 2 +- internal/generatedtext/daily_test.go | 17 ++- internal/generatedtext/day_style.go | 18 ++-- internal/generatedtext/day_style_test.go | 45 ++++---- internal/generatedtext/hourly.go | 17 ++- internal/generatedtext/hourly_test.go | 17 ++- internal/generatedtext/json.go | 8 -- internal/generatedtext/json_test.go | 8 +- internal/generatedtext/render_context_test.go | 2 +- internal/generatedtext/today.go | 2 +- internal/generatedtext/today_test.go | 17 ++- internal/generatedtext/tomorrow.go | 2 +- internal/generatedtext/tomorrow_test.go | 17 ++- internal/promptinput/package.go | 100 ------------------ internal/promptinput/package_test.go | 81 -------------- 20 files changed, 89 insertions(+), 314 deletions(-) diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index 5e94ebc..6d76a4d 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -11,8 +11,8 @@ maintainer-facing context fields belong to [report templates](../templates.md). The Daily, Today, Tomorrow, and Hourly report definitions each use structured generated text. `LookupDefinition` requires the exact report, schema, and template triple and rejects unknown IDs, unsupported pairs, and a pair that -belongs to another report before the run begins. A handler validates raw JSON, -returns a typed value and canonical normalized JSON, loads its canonical schema through +belongs to another report before the run begins. A handler validates and +normalizes raw JSON into a typed value, loads its canonical schema through `internal/promptassets`, builds a render context, and renders through `internal/reporttemplate`. diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index ed11bf5..ac1a7ca 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -1,6 +1,6 @@ # Prompt Input Internals -`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends. +`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends. ## Package Construction @@ -19,9 +19,9 @@ Serialization keeps `metadata` directly under `briefing`. Every other known stan | `narrative_products` | narrative forecast, discussions, and weather story | | `raw_data` | current conditions and hourly forecast | -`LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit. +`Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` validates before serializing. Normal application execution passes marshalled YAML directly to Promptkit. -Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves: +Focused tests cover construction, curated exports, category ordering, serialization, and validation: ```sh go test ./internal/promptinput diff --git a/internal/app/profile_execution.go b/internal/app/profile_execution.go index 86aa338..191512e 100644 --- a/internal/app/profile_execution.go +++ b/internal/app/profile_execution.go @@ -129,7 +129,7 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)} } - generatedText, _, err := req.Prepared.handler.Validate(execution.RawOutput) + generatedText, err := req.Prepared.handler.Validate(execution.RawOutput) if err != nil { return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err} } diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index efe3215..f737040 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -23,7 +23,7 @@ const ( templateIDTomorrow = "tomorrow" ) -type validator func([]byte) (any, []byte, error) +type validator func([]byte) (any, error) type renderContextBuilder func(report.ID, string, briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, any) (any, error) @@ -133,9 +133,9 @@ func (h Handler) Template() (string, error) { return source, nil } -func (h Handler) Validate(data []byte) (any, []byte, error) { +func (h Handler) Validate(data []byte) (any, 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 nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID) } return h.validate(data) } @@ -155,19 +155,19 @@ func (h Handler) Render(data any) ([]byte, error) { return rendered, nil } -func validateHourly(data []byte) (any, []byte, error) { +func validateHourly(data []byte) (any, error) { return ValidateHourly(data) } -func validateDaily(data []byte) (any, []byte, error) { +func validateDaily(data []byte) (any, error) { return ValidateDaily(data) } -func validateToday(data []byte) (any, []byte, error) { +func validateToday(data []byte) (any, error) { return ValidateToday(data) } -func validateTomorrow(data []byte) (any, []byte, error) { +func validateTomorrow(data []byte) (any, error) { return ValidateTomorrow(data) } diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index 682d7e6..3ad0ba5 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -189,7 +189,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if err != nil { t.Fatalf("LookupDefinition(hourly) error = %v", err) } - hourly, normalized, err := hourlyHandler.Validate([]byte(`{ + hourly, err := hourlyHandler.Validate([]byte(`{ "summary": " Storm chances increase. ", "forecast_discussion": " A front will keep the region unsettled. ", "precipitation_timing": "" @@ -200,15 +200,15 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { 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) + if hourly.(Hourly).Summary != "Storm chances increase." { + t.Fatalf("hourly summary = %q, want trimmed summary", hourly.(Hourly).Summary) } tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow)) if err != nil { t.Fatalf("LookupDefinition(tomorrow) error = %v", err) } - tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{ + tomorrow, err := tomorrowHandler.Validate([]byte(`{ "summary": " Storms become more likely tomorrow. ", "forecast_discussion": [" A front will keep showers in the forecast. ", ""], "precipitation_timing": "" @@ -219,8 +219,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { 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) + if tomorrow.(Tomorrow).ForecastDiscussion[0] != "A front will keep showers in the forecast." { + t.Fatalf("tomorrow discussion = %#v, want trimmed paragraph", tomorrow.(Tomorrow).ForecastDiscussion) } todayHandler, err := LookupDefinition(report.Definition{ @@ -231,7 +231,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if err != nil { t.Fatalf("LookupDefinition(today) error = %v", err) } - today, normalized, err := todayHandler.Validate([]byte(`{ + today, err := todayHandler.Validate([]byte(`{ "summary": " Showers are likely today. ", "forecast_discussion": [" A front will keep rain chances elevated. ", ""], "precipitation_timing": "" @@ -242,8 +242,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if _, ok := today.(Today); !ok { t.Fatalf("today generated text type = %T, want generatedtext.Today", today) } - if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) { - t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized) + if today.(Today).ForecastDiscussion[0] != "A front will keep rain chances elevated." { + t.Fatalf("today discussion = %#v, want trimmed paragraph", today.(Today).ForecastDiscussion) } dailyHandler, err := LookupDefinition(report.Definition{ @@ -254,7 +254,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if err != nil { t.Fatalf("LookupDefinition(daily) error = %v", err) } - daily, normalized, err := dailyHandler.Validate([]byte(`{ + daily, err := dailyHandler.Validate([]byte(`{ "summary": " Showers are possible during the selected day. ", "forecast_discussion": [" A front will keep rain chances in the forecast. ", ""], "precipitation_timing": "" @@ -265,8 +265,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) { if _, ok := daily.(Daily); !ok { t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily) } - if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) { - t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized) + if daily.(Daily).ForecastDiscussion[0] != "A front will keep rain chances in the forecast." { + t.Fatalf("daily discussion = %#v, want trimmed paragraph", daily.(Daily).ForecastDiscussion) } } diff --git a/internal/generatedtext/daily.go b/internal/generatedtext/daily.go index 7635fba..a80b622 100644 --- a/internal/generatedtext/daily.go +++ b/internal/generatedtext/daily.go @@ -6,7 +6,7 @@ type Daily struct { PrecipitationTiming string `json:"precipitation_timing"` } -func ValidateDaily(data []byte) (Daily, []byte, error) { +func ValidateDaily(data []byte) (Daily, error) { return validateDayStyleGeneratedText[Daily, *Daily](data, "daily") } diff --git a/internal/generatedtext/daily_test.go b/internal/generatedtext/daily_test.go index 07162da..05adea8 100644 --- a/internal/generatedtext/daily_test.go +++ b/internal/generatedtext/daily_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestValidateDailyNormalizesJSON(t *testing.T) { - value, normalized, err := ValidateDaily([]byte(`{ +func TestValidateDailyNormalizesFields(t *testing.T) { + value, err := ValidateDaily([]byte(`{ "summary": " Showers are possible during the selected day. ", "forecast_discussion": [ " A front will keep rain chances in the forecast. ", @@ -27,14 +27,10 @@ func TestValidateDailyNormalizesJSON(t *testing.T) { if value.PrecipitationTiming != "Rain is most likely during the afternoon." { t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) } - want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon."}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) - } } func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - _, normalized, err := ValidateDaily([]byte(`{ + value, err := ValidateDaily([]byte(`{ "summary": "Showers are possible during the selected day.", "forecast_discussion": ["A front will keep rain chances in the forecast."], "precipitation_timing": " " @@ -42,9 +38,8 @@ func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { if err != nil { t.Fatalf("ValidateDaily() error = %v", err) } - want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":""}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) + if value.PrecipitationTiming != "" { + t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) } } @@ -97,7 +92,7 @@ func TestValidateDailyRejectsInvalidInput(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, _, err := ValidateDaily([]byte(test.in)) + _, err := ValidateDaily([]byte(test.in)) if err == nil { t.Fatal("ValidateDaily() error = nil, want error") } diff --git a/internal/generatedtext/day_style.go b/internal/generatedtext/day_style.go index cd6fd2f..cf5b946 100644 --- a/internal/generatedtext/day_style.go +++ b/internal/generatedtext/day_style.go @@ -19,11 +19,11 @@ type dayStyleGeneratedText interface { func validateDayStyleGeneratedText[T any, PT interface { *T dayStyleGeneratedText -}](data []byte, name string) (T, []byte, error) { +}](data []byte, name string) (T, error) { value, err := decodeGeneratedText[T](data, name) if err != nil { var zero T - return zero, nil, err + return zero, err } pointer := PT(&value) @@ -33,24 +33,18 @@ func validateDayStyleGeneratedText[T any, PT interface { fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion) if fields.Summary == "" { var zero T - return zero, nil, fmt.Errorf("%s generated text summary is required", name) + return zero, fmt.Errorf("%s generated text summary is required", name) } if len(fields.ForecastDiscussion) == 0 { var zero T - return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name) + return zero, fmt.Errorf("%s generated text forecast discussion is required", name) } if err := requireGeneratedTextStringField(data, name, "precipitation_timing"); err != nil { var zero T - return zero, nil, err + return zero, err } pointer.setDayStyleFields(fields) - - normalized, err := normalizeGeneratedText(value, name) - if err != nil { - var zero T - return zero, nil, err - } - return value, normalized, nil + return value, nil } func trimNonEmpty(values []string) []string { diff --git a/internal/generatedtext/day_style_test.go b/internal/generatedtext/day_style_test.go index 4e3f9a0..ea37925 100644 --- a/internal/generatedtext/day_style_test.go +++ b/internal/generatedtext/day_style_test.go @@ -9,27 +9,27 @@ import ( func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { reports := []struct { name string - validate func([]byte) (any, []byte, error) + validate func([]byte) (any, error) }{ { name: "daily", - validate: func(data []byte) (any, []byte, error) { - value, normalized, err := ValidateDaily(data) - return value, normalized, err + validate: func(data []byte) (any, error) { + value, err := ValidateDaily(data) + return value, err }, }, { name: "today", - validate: func(data []byte) (any, []byte, error) { - value, normalized, err := ValidateToday(data) - return value, normalized, err + validate: func(data []byte) (any, error) { + value, err := ValidateToday(data) + return value, err }, }, { name: "tomorrow", - validate: func(data []byte) (any, []byte, error) { - value, normalized, err := ValidateTomorrow(data) - return value, normalized, err + validate: func(data []byte) (any, error) { + value, err := ValidateTomorrow(data) + return value, err }, }, } @@ -37,7 +37,7 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { for _, report := range reports { t.Run(report.name, func(t *testing.T) { t.Run("requires summary", func(t *testing.T) { - _, _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`)) + _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`)) want := fmt.Sprintf("%s generated text summary is required", report.name) if err == nil || err.Error() != want { t.Fatalf("validate() error = %v, want %q", err, want) @@ -45,15 +45,15 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { }) t.Run("requires non-empty forecast discussion", func(t *testing.T) { - _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`)) + _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`)) want := fmt.Sprintf("%s generated text forecast discussion is required", report.name) if err == nil || err.Error() != want { t.Fatalf("validate() error = %v, want %q", err, want) } }) - t.Run("trims and normalizes", func(t *testing.T) { - value, normalized, err := report.validate([]byte(`{ + t.Run("normalizes fields", func(t *testing.T) { + value, err := report.validate([]byte(`{ "summary": " Shared summary. ", "forecast_discussion": [ " First paragraph. ", @@ -75,14 +75,10 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { if fields.PrecipitationTiming != "Afternoon." { t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming) } - want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon."}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) - } }) t.Run("preserves required empty precipitation timing", func(t *testing.T) { - _, normalized, err := report.validate([]byte(`{ + value, err := report.validate([]byte(`{ "summary": "Shared summary.", "forecast_discussion": ["First paragraph."], "precipitation_timing": " " @@ -90,14 +86,13 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { if err != nil { t.Fatalf("validate() error = %v", err) } - want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"precipitation_timing":""}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) + if fields := dayStyleFieldsForTest(t, value); fields.PrecipitationTiming != "" { + t.Fatalf("PrecipitationTiming = %q, want required empty value", fields.PrecipitationTiming) } }) t.Run("requires precipitation timing field", func(t *testing.T) { - _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}`)) + _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}`)) want := fmt.Sprintf("%s generated text precipitation timing is required", report.name) if err == nil || err.Error() != want { t.Fatalf("validate() error = %v, want %q", err, want) @@ -105,7 +100,7 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { }) t.Run("rejects unknown fields", func(t *testing.T) { - _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`)) + _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`)) if err == nil { t.Fatal("validate() error = nil, want error") } @@ -115,7 +110,7 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { }) t.Run("rejects retired confidence field", func(t *testing.T) { - _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"precipitation_timing":"","confidence":"Medium"}`)) + _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"precipitation_timing":"","confidence":"Medium"}`)) if err == nil || !strings.Contains(err.Error(), "unsupported field") { t.Fatalf("validate() error = %v, want retired confidence field rejection", err) } diff --git a/internal/generatedtext/hourly.go b/internal/generatedtext/hourly.go index ae8eeed..ee72a12 100644 --- a/internal/generatedtext/hourly.go +++ b/internal/generatedtext/hourly.go @@ -12,28 +12,23 @@ type Hourly struct { PrecipitationTiming string `json:"precipitation_timing"` } -func ValidateHourly(data []byte) (Hourly, []byte, error) { +func ValidateHourly(data []byte) (Hourly, error) { value, err := decodeGeneratedText[Hourly](data, "hourly") if err != nil { - return Hourly{}, nil, err + return Hourly{}, err } value.Summary = strings.TrimSpace(value.Summary) value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion) value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) if value.Summary == "" { - return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required") + return Hourly{}, fmt.Errorf("hourly generated text summary is required") } if value.ForecastDiscussion == "" { - return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required") + return Hourly{}, fmt.Errorf("hourly generated text forecast discussion is required") } if err := requireGeneratedTextStringField(data, "hourly", "precipitation_timing"); err != nil { - return Hourly{}, nil, err + return Hourly{}, err } - - normalized, err := normalizeGeneratedText(value, "hourly") - if err != nil { - return Hourly{}, nil, err - } - return value, normalized, nil + return value, nil } diff --git a/internal/generatedtext/hourly_test.go b/internal/generatedtext/hourly_test.go index 985eb62..3f66e2c 100644 --- a/internal/generatedtext/hourly_test.go +++ b/internal/generatedtext/hourly_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestValidateHourlyNormalizesJSON(t *testing.T) { - value, normalized, err := ValidateHourly([]byte(`{ +func TestValidateHourlyNormalizesFields(t *testing.T) { + value, err := ValidateHourly([]byte(`{ "summary": " Storm chances increase. ", "forecast_discussion": " A front will keep the region unsettled. ", "precipitation_timing": " Showers are most likely early this afternoon. " @@ -23,14 +23,10 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) { if value.PrecipitationTiming != "Showers are most likely early this afternoon." { t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) } - want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon."}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) - } } func TestValidateHourlyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - _, normalized, err := ValidateHourly([]byte(`{ + value, err := ValidateHourly([]byte(`{ "summary": "Storm chances increase.", "forecast_discussion": "A front will keep the region unsettled.", "precipitation_timing": " " @@ -38,9 +34,8 @@ func TestValidateHourlyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { if err != nil { t.Fatalf("ValidateHourly() error = %v", err) } - want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":""}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) + if value.PrecipitationTiming != "" { + t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) } } @@ -103,7 +98,7 @@ func TestValidateHourlyRejectsInvalidInput(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, _, err := ValidateHourly([]byte(test.in)) + _, err := ValidateHourly([]byte(test.in)) if err == nil { t.Fatal("ValidateHourly() error = nil, want error") } diff --git a/internal/generatedtext/json.go b/internal/generatedtext/json.go index 81f1edc..aa19b2c 100644 --- a/internal/generatedtext/json.go +++ b/internal/generatedtext/json.go @@ -89,11 +89,3 @@ func requireGeneratedTextStringField(data []byte, name, field string) error { } return nil } - -func normalizeGeneratedText[T any](value T, name string) ([]byte, error) { - normalized, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("normalize %s generated text: %w", name, err) - } - return normalized, nil -} diff --git a/internal/generatedtext/json_test.go b/internal/generatedtext/json_test.go index 1255cfa..9dcf811 100644 --- a/internal/generatedtext/json_test.go +++ b/internal/generatedtext/json_test.go @@ -160,21 +160,21 @@ func generatedTextSchema(t *testing.T, id string) *jsonschema.Schema { } func validateDailyJSON(data []byte) error { - _, _, err := ValidateDaily(data) + _, err := ValidateDaily(data) return err } func validateTodayJSON(data []byte) error { - _, _, err := ValidateToday(data) + _, err := ValidateToday(data) return err } func validateTomorrowJSON(data []byte) error { - _, _, err := ValidateTomorrow(data) + _, err := ValidateTomorrow(data) return err } func validateHourlyJSON(data []byte) error { - _, _, err := ValidateHourly(data) + _, err := ValidateHourly(data) return err } diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 20f42b0..83956bb 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -628,7 +628,7 @@ func TestBuildDailyRenderContext(t *testing.T) { } func TestValidatedGeneratedTextRendersAsPlainText(t *testing.T) { - generated, _, err := ValidateDaily([]byte("{\"summary\":\"Summary.\\n## Fabricated Alert\\n- Fabricated warning\",\"forecast_discussion\":[\"Discussion with [unsafe](javascript:alert(1))\"],\"precipitation_timing\":\"Timing.\\n```not code```\"}")) + generated, err := ValidateDaily([]byte("{\"summary\":\"Summary.\\n## Fabricated Alert\\n- Fabricated warning\",\"forecast_discussion\":[\"Discussion with [unsafe](javascript:alert(1))\"],\"precipitation_timing\":\"Timing.\\n```not code```\"}")) if err != nil { t.Fatalf("ValidateDaily() error = %v", err) } diff --git a/internal/generatedtext/today.go b/internal/generatedtext/today.go index 98ff6cd..94b96c5 100644 --- a/internal/generatedtext/today.go +++ b/internal/generatedtext/today.go @@ -6,7 +6,7 @@ type Today struct { PrecipitationTiming string `json:"precipitation_timing"` } -func ValidateToday(data []byte) (Today, []byte, error) { +func ValidateToday(data []byte) (Today, error) { return validateDayStyleGeneratedText[Today, *Today](data, "today") } diff --git a/internal/generatedtext/today_test.go b/internal/generatedtext/today_test.go index 03a8e42..19c3fa8 100644 --- a/internal/generatedtext/today_test.go +++ b/internal/generatedtext/today_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestValidateTodayNormalizesJSON(t *testing.T) { - value, normalized, err := ValidateToday([]byte(`{ +func TestValidateTodayNormalizesFields(t *testing.T) { + value, err := ValidateToday([]byte(`{ "summary": " Showers are likely today. ", "forecast_discussion": [ " A front will keep rain chances elevated. ", @@ -27,14 +27,10 @@ func TestValidateTodayNormalizesJSON(t *testing.T) { if value.PrecipitationTiming != "Rain is most likely during the afternoon." { t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) } - want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon."}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) - } } func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - _, normalized, err := ValidateToday([]byte(`{ + value, err := ValidateToday([]byte(`{ "summary": "Showers are likely today.", "forecast_discussion": ["A front will keep rain chances elevated."], "precipitation_timing": " " @@ -42,9 +38,8 @@ func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { if err != nil { t.Fatalf("ValidateToday() error = %v", err) } - want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"precipitation_timing":""}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) + if value.PrecipitationTiming != "" { + t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) } } @@ -97,7 +92,7 @@ func TestValidateTodayRejectsInvalidInput(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, _, err := ValidateToday([]byte(test.in)) + _, err := ValidateToday([]byte(test.in)) if err == nil { t.Fatal("ValidateToday() error = nil, want error") } diff --git a/internal/generatedtext/tomorrow.go b/internal/generatedtext/tomorrow.go index 26022ba..ee113ec 100644 --- a/internal/generatedtext/tomorrow.go +++ b/internal/generatedtext/tomorrow.go @@ -6,7 +6,7 @@ type Tomorrow struct { PrecipitationTiming string `json:"precipitation_timing"` } -func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { +func ValidateTomorrow(data []byte) (Tomorrow, error) { return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow") } diff --git a/internal/generatedtext/tomorrow_test.go b/internal/generatedtext/tomorrow_test.go index ca5b490..2f2432b 100644 --- a/internal/generatedtext/tomorrow_test.go +++ b/internal/generatedtext/tomorrow_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestValidateTomorrowNormalizesJSON(t *testing.T) { - value, normalized, err := ValidateTomorrow([]byte(`{ +func TestValidateTomorrowNormalizesFields(t *testing.T) { + value, err := ValidateTomorrow([]byte(`{ "summary": " Storms become more likely tomorrow. ", "forecast_discussion": [ " A front will keep showers in the forecast. ", @@ -27,14 +27,10 @@ func TestValidateTomorrowNormalizesJSON(t *testing.T) { 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."}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) - } } func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - _, normalized, err := ValidateTomorrow([]byte(`{ + value, err := ValidateTomorrow([]byte(`{ "summary": "Storms become more likely tomorrow.", "forecast_discussion": ["A front will keep showers in the forecast."], "precipitation_timing": " " @@ -42,9 +38,8 @@ func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T) 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."],"precipitation_timing":""}` - if string(normalized) != want { - t.Fatalf("normalized = %s, want %s", normalized, want) + if value.PrecipitationTiming != "" { + t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) } } @@ -97,7 +92,7 @@ func TestValidateTomorrowRejectsInvalidInput(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, _, err := ValidateTomorrow([]byte(test.in)) + _, err := ValidateTomorrow([]byte(test.in)) if err == nil { t.Fatal("ValidateTomorrow() error = nil, want error") } diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index ea353d2..4ab529c 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -2,12 +2,10 @@ package promptinput import ( - "bytes" "encoding/json" "fmt" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -210,17 +208,6 @@ func Validate(pkg Package) error { return nil } -func Save(path string, pkg Package) error { - data, err := MarshalYAML(pkg) - if err != nil { - return err - } - if err := fileutil.WriteFileAtomic(path, data); err != nil { - return fmt.Errorf("save data package: %w", err) - } - return nil -} - func MarshalYAML(pkg Package) ([]byte, error) { if err := Validate(pkg); err != nil { return nil, err @@ -232,18 +219,6 @@ func MarshalYAML(pkg Package) ([]byte, error) { return data, nil } -func LoadYAML(data []byte) (Package, error) { - var pkg Package - decoder := yaml.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(&pkg); err != nil { - return Package{}, fmt.Errorf("decode data package: %w", err) - } - if err := Validate(pkg); err != nil { - return Package{}, err - } - return pkg, nil -} - func (b BriefingStanzas) MarshalYAML() (any, error) { node := &yaml.Node{Kind: yaml.MappingNode} categoryNames := map[string][]string{} @@ -284,58 +259,6 @@ func (b BriefingStanzas) MarshalYAML() (any, error) { return node, nil } -func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error { - if value.Kind != yaml.MappingNode { - return fmt.Errorf("briefing must be a mapping") - } - values := map[string]any{} - order := make([]string, 0, len(value.Content)/2) - seen := map[string]struct{}{} - seenCategories := map[string]struct{}{} - categoryOrder := map[string][]string{} - for i := 0; i < len(value.Content); i += 2 { - name := value.Content[i].Value - if name == metadataStanza { - if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil { - return err - } - continue - } - if !knownBriefingCategory(name) { - return fmt.Errorf("unknown briefing category %q", name) - } - if _, ok := seenCategories[name]; ok { - return fmt.Errorf("duplicate briefing category %q", name) - } - seenCategories[name] = struct{}{} - categoryNode := value.Content[i+1] - if categoryNode.Kind != yaml.MappingNode { - return fmt.Errorf("briefing category %q must be a mapping", name) - } - var names []string - for j := 0; j < len(categoryNode.Content); j += 2 { - stanzaName := categoryNode.Content[j].Value - category, ok := briefingStanzaCategories[stanzaName] - if !ok { - return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName) - } - if category != name { - return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name) - } - if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil { - return err - } - } - categoryOrder[name] = names - } - for _, category := range briefingCategoryOrder { - order = append(order, categoryOrder[category]...) - } - b.Order = order - b.Values = values - return nil -} - func (b BriefingStanzas) MarshalJSON() ([]byte, error) { out := map[string]any{} for _, name := range b.Order { @@ -370,29 +293,6 @@ func appendYAMLMappingValue(node *yaml.Node, name string, value any) error { return nil } -func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error { - if _, ok := seen[name]; ok { - return fmt.Errorf("duplicate briefing stanza %q", name) - } - var stanza any - if err := node.Decode(&stanza); err != nil { - return err - } - seen[name] = struct{}{} - *order = append(*order, name) - values[name] = stanza - return nil -} - -func knownBriefingCategory(name string) bool { - for _, category := range briefingCategoryOrder { - if name == category { - return true - } - } - return false -} - func yamlNode(value any) (*yaml.Node, error) { data, err := json.Marshal(value) if err != nil { diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index 796889d..0b875c1 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -271,43 +271,6 @@ func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) { t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text) } - loaded, err := LoadYAML(data) - if err != nil { - t.Fatalf("LoadYAML() error = %v", err) - } - if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok { - t.Fatal("loaded package missing spc_convective_outlooks stanza") - } - if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok { - t.Fatal("loaded package missing spc_convective_discussion stanza") - } -} - -func TestLoadYAMLRoundTrip(t *testing.T) { - pkg, err := Build(validBuildRequest(t)) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - data, err := MarshalYAML(pkg) - if err != nil { - t.Fatalf("MarshalYAML() error = %v", err) - } - - loaded, err := LoadYAML(data) - if err != nil { - t.Fatalf("LoadYAML() error = %v", err) - } - - if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID { - t.Fatalf("loaded package = %#v, want schema and run id", loaded) - } - wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"} - if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") { - t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder) - } - if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" { - t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got) - } } func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) { @@ -337,50 +300,6 @@ func TestMarshalYAMLAttributesStanzaSerializationFailures(t *testing.T) { } } -func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { - data := []byte(` -schema_version: weatherreporter.data_package.v4 -run_id: 20260529T100000Z_daily -report: - id: daily - prompt_id: weather.daily_generated_text - generated_at: 2026-05-29T10:00:00Z - timezone: America/Chicago - current_local_date: "2026-05-29" - valid_period: - start: 2026-05-29T05:00:00Z - end: 2026-05-30T05:00:00Z -briefing: - metadata: - run_id: 20260529T100000Z_daily - raw_data: - alert_digest: - checked: true -`) - - _, err := LoadYAML(data) - if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) { - t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err) - } -} - -func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) { - pkg, err := Build(validBuildRequest(t)) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - data, err := MarshalYAML(pkg) - if err != nil { - t.Fatalf("MarshalYAML() error = %v", err) - } - data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v4", "weatherreporter.data_package.v3", 1)) - - _, err = LoadYAML(data) - if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v4") { - t.Fatalf("LoadYAML() error = %v, want current schema version error", err) - } -} - func validBuildRequest(t *testing.T) BuildRequest { t.Helper() generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)