Retire dormant prompt compatibility APIs
This commit is contained in:
@@ -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
|
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||||
generated text. `LookupDefinition` requires the exact report, schema, and
|
generated text. `LookupDefinition` requires the exact report, schema, and
|
||||||
template triple and rejects unknown IDs, unsupported pairs, and a pair that
|
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,
|
belongs to another report before the run begins. A handler validates and
|
||||||
returns a typed value and canonical normalized JSON, loads its canonical schema through
|
normalizes raw JSON into a typed value, loads its canonical schema through
|
||||||
`internal/promptassets`, builds a render context, and renders through
|
`internal/promptassets`, builds a render context, and renders through
|
||||||
`internal/reporttemplate`.
|
`internal/reporttemplate`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Prompt Input Internals
|
# 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
|
## Package Construction
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@ Serialization keeps `metadata` directly under `briefing`. Every other known stan
|
|||||||
| `narrative_products` | narrative forecast, discussions, and weather story |
|
| `narrative_products` | narrative forecast, discussions, and weather story |
|
||||||
| `raw_data` | current conditions and hourly forecast |
|
| `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
|
```sh
|
||||||
go test ./internal/promptinput
|
go test ./internal/promptinput
|
||||||
|
|||||||
@@ -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)}
|
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 {
|
if err != nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
templateIDTomorrow = "tomorrow"
|
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)
|
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
|
return source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) Validate(data []byte) (any, []byte, error) {
|
func (h Handler) Validate(data []byte) (any, error) {
|
||||||
if h.validate == nil {
|
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)
|
return h.validate(data)
|
||||||
}
|
}
|
||||||
@@ -155,19 +155,19 @@ func (h Handler) Render(data any) ([]byte, error) {
|
|||||||
return rendered, nil
|
return rendered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHourly(data []byte) (any, []byte, error) {
|
func validateHourly(data []byte) (any, error) {
|
||||||
return ValidateHourly(data)
|
return ValidateHourly(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDaily(data []byte) (any, []byte, error) {
|
func validateDaily(data []byte) (any, error) {
|
||||||
return ValidateDaily(data)
|
return ValidateDaily(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateToday(data []byte) (any, []byte, error) {
|
func validateToday(data []byte) (any, error) {
|
||||||
return ValidateToday(data)
|
return ValidateToday(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateTomorrow(data []byte) (any, []byte, error) {
|
func validateTomorrow(data []byte) (any, error) {
|
||||||
return ValidateTomorrow(data)
|
return ValidateTomorrow(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
||||||
}
|
}
|
||||||
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
|
hourly, err := hourlyHandler.Validate([]byte(`{
|
||||||
"summary": " Storm chances increase. ",
|
"summary": " Storm chances increase. ",
|
||||||
"forecast_discussion": " A front will keep the region unsettled. ",
|
"forecast_discussion": " A front will keep the region unsettled. ",
|
||||||
"precipitation_timing": ""
|
"precipitation_timing": ""
|
||||||
@@ -200,15 +200,15 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if _, ok := hourly.(Hourly); !ok {
|
if _, ok := hourly.(Hourly); !ok {
|
||||||
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
|
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
|
if hourly.(Hourly).Summary != "Storm chances increase." {
|
||||||
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
|
t.Fatalf("hourly summary = %q, want trimmed summary", hourly.(Hourly).Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
||||||
}
|
}
|
||||||
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
|
tomorrow, err := tomorrowHandler.Validate([]byte(`{
|
||||||
"summary": " Storms become more likely tomorrow. ",
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
"forecast_discussion": [" A front will keep showers in the forecast. ", ""],
|
"forecast_discussion": [" A front will keep showers in the forecast. ", ""],
|
||||||
"precipitation_timing": ""
|
"precipitation_timing": ""
|
||||||
@@ -219,8 +219,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if _, ok := tomorrow.(Tomorrow); !ok {
|
if _, ok := tomorrow.(Tomorrow); !ok {
|
||||||
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
|
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."]`) {
|
if tomorrow.(Tomorrow).ForecastDiscussion[0] != "A front will keep showers in the forecast." {
|
||||||
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
|
t.Fatalf("tomorrow discussion = %#v, want trimmed paragraph", tomorrow.(Tomorrow).ForecastDiscussion)
|
||||||
}
|
}
|
||||||
|
|
||||||
todayHandler, err := LookupDefinition(report.Definition{
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
@@ -231,7 +231,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupDefinition(today) error = %v", err)
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
}
|
}
|
||||||
today, normalized, err := todayHandler.Validate([]byte(`{
|
today, err := todayHandler.Validate([]byte(`{
|
||||||
"summary": " Showers are likely today. ",
|
"summary": " Showers are likely today. ",
|
||||||
"forecast_discussion": [" A front will keep rain chances elevated. ", ""],
|
"forecast_discussion": [" A front will keep rain chances elevated. ", ""],
|
||||||
"precipitation_timing": ""
|
"precipitation_timing": ""
|
||||||
@@ -242,8 +242,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if _, ok := today.(Today); !ok {
|
if _, ok := today.(Today); !ok {
|
||||||
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
|
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."]`) {
|
if today.(Today).ForecastDiscussion[0] != "A front will keep rain chances elevated." {
|
||||||
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
|
t.Fatalf("today discussion = %#v, want trimmed paragraph", today.(Today).ForecastDiscussion)
|
||||||
}
|
}
|
||||||
|
|
||||||
dailyHandler, err := LookupDefinition(report.Definition{
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
@@ -254,7 +254,7 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
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. ",
|
"summary": " Showers are possible during the selected day. ",
|
||||||
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""],
|
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""],
|
||||||
"precipitation_timing": ""
|
"precipitation_timing": ""
|
||||||
@@ -265,8 +265,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
|||||||
if _, ok := daily.(Daily); !ok {
|
if _, ok := daily.(Daily); !ok {
|
||||||
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
|
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."]`) {
|
if daily.(Daily).ForecastDiscussion[0] != "A front will keep rain chances in the forecast." {
|
||||||
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
|
t.Fatalf("daily discussion = %#v, want trimmed paragraph", daily.(Daily).ForecastDiscussion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Daily struct {
|
|||||||
PrecipitationTiming string `json:"precipitation_timing"`
|
PrecipitationTiming string `json:"precipitation_timing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateDaily(data []byte) (Daily, []byte, error) {
|
func ValidateDaily(data []byte) (Daily, error) {
|
||||||
return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
|
return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateDailyNormalizesJSON(t *testing.T) {
|
func TestValidateDailyNormalizesFields(t *testing.T) {
|
||||||
value, normalized, err := ValidateDaily([]byte(`{
|
value, err := ValidateDaily([]byte(`{
|
||||||
"summary": " Showers are possible during the selected day. ",
|
"summary": " Showers are possible during the selected day. ",
|
||||||
"forecast_discussion": [
|
"forecast_discussion": [
|
||||||
" A front will keep rain chances in the forecast. ",
|
" 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." {
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
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) {
|
func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
||||||
_, normalized, err := ValidateDaily([]byte(`{
|
value, err := ValidateDaily([]byte(`{
|
||||||
"summary": "Showers are possible during the selected day.",
|
"summary": "Showers are possible during the selected day.",
|
||||||
"forecast_discussion": ["A front will keep rain chances in the forecast."],
|
"forecast_discussion": ["A front will keep rain chances in the forecast."],
|
||||||
"precipitation_timing": " "
|
"precipitation_timing": " "
|
||||||
@@ -42,9 +38,8 @@ func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateDaily() error = %v", err)
|
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 value.PrecipitationTiming != "" {
|
||||||
if string(normalized) != want {
|
t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming)
|
||||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +92,7 @@ func TestValidateDailyRejectsInvalidInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, _, err := ValidateDaily([]byte(test.in))
|
_, err := ValidateDaily([]byte(test.in))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ValidateDaily() error = nil, want error")
|
t.Fatal("ValidateDaily() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ type dayStyleGeneratedText interface {
|
|||||||
func validateDayStyleGeneratedText[T any, PT interface {
|
func validateDayStyleGeneratedText[T any, PT interface {
|
||||||
*T
|
*T
|
||||||
dayStyleGeneratedText
|
dayStyleGeneratedText
|
||||||
}](data []byte, name string) (T, []byte, error) {
|
}](data []byte, name string) (T, error) {
|
||||||
value, err := decodeGeneratedText[T](data, name)
|
value, err := decodeGeneratedText[T](data, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var zero T
|
var zero T
|
||||||
return zero, nil, err
|
return zero, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pointer := PT(&value)
|
pointer := PT(&value)
|
||||||
@@ -33,24 +33,18 @@ func validateDayStyleGeneratedText[T any, PT interface {
|
|||||||
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
|
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
|
||||||
if fields.Summary == "" {
|
if fields.Summary == "" {
|
||||||
var zero T
|
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 {
|
if len(fields.ForecastDiscussion) == 0 {
|
||||||
var zero T
|
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 {
|
if err := requireGeneratedTextStringField(data, name, "precipitation_timing"); err != nil {
|
||||||
var zero T
|
var zero T
|
||||||
return zero, nil, err
|
return zero, err
|
||||||
}
|
}
|
||||||
pointer.setDayStyleFields(fields)
|
pointer.setDayStyleFields(fields)
|
||||||
|
return value, nil
|
||||||
normalized, err := normalizeGeneratedText(value, name)
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
return zero, nil, err
|
|
||||||
}
|
|
||||||
return value, normalized, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimNonEmpty(values []string) []string {
|
func trimNonEmpty(values []string) []string {
|
||||||
|
|||||||
@@ -9,27 +9,27 @@ import (
|
|||||||
func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
||||||
reports := []struct {
|
reports := []struct {
|
||||||
name string
|
name string
|
||||||
validate func([]byte) (any, []byte, error)
|
validate func([]byte) (any, error)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "daily",
|
name: "daily",
|
||||||
validate: func(data []byte) (any, []byte, error) {
|
validate: func(data []byte) (any, error) {
|
||||||
value, normalized, err := ValidateDaily(data)
|
value, err := ValidateDaily(data)
|
||||||
return value, normalized, err
|
return value, err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "today",
|
name: "today",
|
||||||
validate: func(data []byte) (any, []byte, error) {
|
validate: func(data []byte) (any, error) {
|
||||||
value, normalized, err := ValidateToday(data)
|
value, err := ValidateToday(data)
|
||||||
return value, normalized, err
|
return value, err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tomorrow",
|
name: "tomorrow",
|
||||||
validate: func(data []byte) (any, []byte, error) {
|
validate: func(data []byte) (any, error) {
|
||||||
value, normalized, err := ValidateTomorrow(data)
|
value, err := ValidateTomorrow(data)
|
||||||
return value, normalized, err
|
return value, err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
|||||||
for _, report := range reports {
|
for _, report := range reports {
|
||||||
t.Run(report.name, func(t *testing.T) {
|
t.Run(report.name, func(t *testing.T) {
|
||||||
t.Run("requires summary", 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)
|
want := fmt.Sprintf("%s generated text summary is required", report.name)
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Fatalf("validate() error = %v, want %q", err, 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) {
|
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)
|
want := fmt.Sprintf("%s generated text forecast discussion is required", report.name)
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Fatalf("validate() error = %v, want %q", err, want)
|
t.Fatalf("validate() error = %v, want %q", err, want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("trims and normalizes", func(t *testing.T) {
|
t.Run("normalizes fields", func(t *testing.T) {
|
||||||
value, normalized, err := report.validate([]byte(`{
|
value, err := report.validate([]byte(`{
|
||||||
"summary": " Shared summary. ",
|
"summary": " Shared summary. ",
|
||||||
"forecast_discussion": [
|
"forecast_discussion": [
|
||||||
" First paragraph. ",
|
" First paragraph. ",
|
||||||
@@ -75,14 +75,10 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
|||||||
if fields.PrecipitationTiming != "Afternoon." {
|
if fields.PrecipitationTiming != "Afternoon." {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming)
|
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) {
|
t.Run("preserves required empty precipitation timing", func(t *testing.T) {
|
||||||
_, normalized, err := report.validate([]byte(`{
|
value, err := report.validate([]byte(`{
|
||||||
"summary": "Shared summary.",
|
"summary": "Shared summary.",
|
||||||
"forecast_discussion": ["First paragraph."],
|
"forecast_discussion": ["First paragraph."],
|
||||||
"precipitation_timing": " "
|
"precipitation_timing": " "
|
||||||
@@ -90,14 +86,13 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("validate() error = %v", err)
|
t.Fatalf("validate() error = %v", err)
|
||||||
}
|
}
|
||||||
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"precipitation_timing":""}`
|
if fields := dayStyleFieldsForTest(t, value); fields.PrecipitationTiming != "" {
|
||||||
if string(normalized) != want {
|
t.Fatalf("PrecipitationTiming = %q, want required empty value", fields.PrecipitationTiming)
|
||||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("requires precipitation timing field", func(t *testing.T) {
|
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)
|
want := fmt.Sprintf("%s generated text precipitation timing is required", report.name)
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Fatalf("validate() error = %v, want %q", err, 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) {
|
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 {
|
if err == nil {
|
||||||
t.Fatal("validate() error = nil, want error")
|
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) {
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "unsupported field") {
|
||||||
t.Fatalf("validate() error = %v, want retired confidence field rejection", err)
|
t.Fatalf("validate() error = %v, want retired confidence field rejection", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,28 +12,23 @@ type Hourly struct {
|
|||||||
PrecipitationTiming string `json:"precipitation_timing"`
|
PrecipitationTiming string `json:"precipitation_timing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateHourly(data []byte) (Hourly, []byte, error) {
|
func ValidateHourly(data []byte) (Hourly, error) {
|
||||||
value, err := decodeGeneratedText[Hourly](data, "hourly")
|
value, err := decodeGeneratedText[Hourly](data, "hourly")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Hourly{}, nil, err
|
return Hourly{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
value.Summary = strings.TrimSpace(value.Summary)
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
|
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
|
||||||
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
if value.Summary == "" {
|
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 == "" {
|
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 {
|
if err := requireGeneratedTextStringField(data, "hourly", "precipitation_timing"); err != nil {
|
||||||
return Hourly{}, nil, err
|
return Hourly{}, err
|
||||||
}
|
}
|
||||||
|
return value, nil
|
||||||
normalized, err := normalizeGeneratedText(value, "hourly")
|
|
||||||
if err != nil {
|
|
||||||
return Hourly{}, nil, err
|
|
||||||
}
|
|
||||||
return value, normalized, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateHourlyNormalizesJSON(t *testing.T) {
|
func TestValidateHourlyNormalizesFields(t *testing.T) {
|
||||||
value, normalized, err := ValidateHourly([]byte(`{
|
value, err := ValidateHourly([]byte(`{
|
||||||
"summary": " Storm chances increase. ",
|
"summary": " Storm chances increase. ",
|
||||||
"forecast_discussion": " A front will keep the region unsettled. ",
|
"forecast_discussion": " A front will keep the region unsettled. ",
|
||||||
"precipitation_timing": " Showers are most likely early this afternoon. "
|
"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." {
|
if value.PrecipitationTiming != "Showers are most likely early this afternoon." {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
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) {
|
func TestValidateHourlyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
||||||
_, normalized, err := ValidateHourly([]byte(`{
|
value, err := ValidateHourly([]byte(`{
|
||||||
"summary": "Storm chances increase.",
|
"summary": "Storm chances increase.",
|
||||||
"forecast_discussion": "A front will keep the region unsettled.",
|
"forecast_discussion": "A front will keep the region unsettled.",
|
||||||
"precipitation_timing": " "
|
"precipitation_timing": " "
|
||||||
@@ -38,9 +34,8 @@ func TestValidateHourlyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateHourly() error = %v", err)
|
t.Fatalf("ValidateHourly() error = %v", err)
|
||||||
}
|
}
|
||||||
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":""}`
|
if value.PrecipitationTiming != "" {
|
||||||
if string(normalized) != want {
|
t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming)
|
||||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +98,7 @@ func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, _, err := ValidateHourly([]byte(test.in))
|
_, err := ValidateHourly([]byte(test.in))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ValidateHourly() error = nil, want error")
|
t.Fatal("ValidateHourly() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,11 +89,3 @@ func requireGeneratedTextStringField(data []byte, name, field string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -160,21 +160,21 @@ func generatedTextSchema(t *testing.T, id string) *jsonschema.Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateDailyJSON(data []byte) error {
|
func validateDailyJSON(data []byte) error {
|
||||||
_, _, err := ValidateDaily(data)
|
_, err := ValidateDaily(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateTodayJSON(data []byte) error {
|
func validateTodayJSON(data []byte) error {
|
||||||
_, _, err := ValidateToday(data)
|
_, err := ValidateToday(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateTomorrowJSON(data []byte) error {
|
func validateTomorrowJSON(data []byte) error {
|
||||||
_, _, err := ValidateTomorrow(data)
|
_, err := ValidateTomorrow(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHourlyJSON(data []byte) error {
|
func validateHourlyJSON(data []byte) error {
|
||||||
_, _, err := ValidateHourly(data)
|
_, err := ValidateHourly(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ func TestBuildDailyRenderContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatedGeneratedTextRendersAsPlainText(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 {
|
if err != nil {
|
||||||
t.Fatalf("ValidateDaily() error = %v", err)
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Today struct {
|
|||||||
PrecipitationTiming string `json:"precipitation_timing"`
|
PrecipitationTiming string `json:"precipitation_timing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateToday(data []byte) (Today, []byte, error) {
|
func ValidateToday(data []byte) (Today, error) {
|
||||||
return validateDayStyleGeneratedText[Today, *Today](data, "today")
|
return validateDayStyleGeneratedText[Today, *Today](data, "today")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateTodayNormalizesJSON(t *testing.T) {
|
func TestValidateTodayNormalizesFields(t *testing.T) {
|
||||||
value, normalized, err := ValidateToday([]byte(`{
|
value, err := ValidateToday([]byte(`{
|
||||||
"summary": " Showers are likely today. ",
|
"summary": " Showers are likely today. ",
|
||||||
"forecast_discussion": [
|
"forecast_discussion": [
|
||||||
" A front will keep rain chances elevated. ",
|
" 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." {
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
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) {
|
func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
||||||
_, normalized, err := ValidateToday([]byte(`{
|
value, err := ValidateToday([]byte(`{
|
||||||
"summary": "Showers are likely today.",
|
"summary": "Showers are likely today.",
|
||||||
"forecast_discussion": ["A front will keep rain chances elevated."],
|
"forecast_discussion": ["A front will keep rain chances elevated."],
|
||||||
"precipitation_timing": " "
|
"precipitation_timing": " "
|
||||||
@@ -42,9 +38,8 @@ func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateToday() error = %v", err)
|
t.Fatalf("ValidateToday() error = %v", err)
|
||||||
}
|
}
|
||||||
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"precipitation_timing":""}`
|
if value.PrecipitationTiming != "" {
|
||||||
if string(normalized) != want {
|
t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming)
|
||||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +92,7 @@ func TestValidateTodayRejectsInvalidInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, _, err := ValidateToday([]byte(test.in))
|
_, err := ValidateToday([]byte(test.in))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ValidateToday() error = nil, want error")
|
t.Fatal("ValidateToday() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Tomorrow struct {
|
|||||||
PrecipitationTiming string `json:"precipitation_timing"`
|
PrecipitationTiming string `json:"precipitation_timing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
|
func ValidateTomorrow(data []byte) (Tomorrow, error) {
|
||||||
return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
|
return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
|
func TestValidateTomorrowNormalizesFields(t *testing.T) {
|
||||||
value, normalized, err := ValidateTomorrow([]byte(`{
|
value, err := ValidateTomorrow([]byte(`{
|
||||||
"summary": " Storms become more likely tomorrow. ",
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
"forecast_discussion": [
|
"forecast_discussion": [
|
||||||
" A front will keep showers in the forecast. ",
|
" 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." {
|
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
|
||||||
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
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) {
|
func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
|
||||||
_, normalized, err := ValidateTomorrow([]byte(`{
|
value, err := ValidateTomorrow([]byte(`{
|
||||||
"summary": "Storms become more likely tomorrow.",
|
"summary": "Storms become more likely tomorrow.",
|
||||||
"forecast_discussion": ["A front will keep showers in the forecast."],
|
"forecast_discussion": ["A front will keep showers in the forecast."],
|
||||||
"precipitation_timing": " "
|
"precipitation_timing": " "
|
||||||
@@ -42,9 +38,8 @@ func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateTomorrow() error = %v", err)
|
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 value.PrecipitationTiming != "" {
|
||||||
if string(normalized) != want {
|
t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming)
|
||||||
t.Fatalf("normalized = %s, want %s", normalized, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +92,7 @@ func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, _, err := ValidateTomorrow([]byte(test.in))
|
_, err := ValidateTomorrow([]byte(test.in))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ValidateTomorrow() error = nil, want error")
|
t.Fatal("ValidateTomorrow() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
package promptinput
|
package promptinput
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -210,17 +208,6 @@ func Validate(pkg Package) error {
|
|||||||
return nil
|
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) {
|
func MarshalYAML(pkg Package) ([]byte, error) {
|
||||||
if err := Validate(pkg); err != nil {
|
if err := Validate(pkg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -232,18 +219,6 @@ func MarshalYAML(pkg Package) ([]byte, error) {
|
|||||||
return data, nil
|
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) {
|
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
||||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
categoryNames := map[string][]string{}
|
categoryNames := map[string][]string{}
|
||||||
@@ -284,58 +259,6 @@ func (b BriefingStanzas) MarshalYAML() (any, error) {
|
|||||||
return node, nil
|
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) {
|
func (b BriefingStanzas) MarshalJSON() ([]byte, error) {
|
||||||
out := map[string]any{}
|
out := map[string]any{}
|
||||||
for _, name := range b.Order {
|
for _, name := range b.Order {
|
||||||
@@ -370,29 +293,6 @@ func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
|
|||||||
return nil
|
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) {
|
func yamlNode(value any) (*yaml.Node, error) {
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -271,43 +271,6 @@ func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
|
|||||||
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
|
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) {
|
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 {
|
func validBuildRequest(t *testing.T) BuildRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
Reference in New Issue
Block a user