Bound generated text content and diagnostics
This commit is contained in:
120
internal/generatedtext/limits.go
Normal file
120
internal/generatedtext/limits.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxGeneratedTextBytes is the largest provider response accepted for one
|
||||
// generated-text report before JSON decoding begins.
|
||||
MaxGeneratedTextBytes = 64 * 1024
|
||||
|
||||
maxGeneratedTextCharacters = 20_000
|
||||
maxGeneratedTextStringCharacters = 4_000
|
||||
maxHourlyDiscussionCharacters = 12_000
|
||||
maxDayStyleDiscussionParagraphs = 12
|
||||
maxDayStyleDiscussionParagraphChars = 4_000
|
||||
)
|
||||
|
||||
// ValidateRawOutput rejects generated JSON before it is decoded or retained.
|
||||
func ValidateRawOutput(data []byte) error {
|
||||
if len(data) > MaxGeneratedTextBytes {
|
||||
return fmt.Errorf("generated text exceeds the %d-byte limit", MaxGeneratedTextBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGeneratedTextSize(data []byte, name string) error {
|
||||
if err := ValidateRawOutput(data); err != nil {
|
||||
return fmt.Errorf("%s %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGeneratedTextField(name, field string, raw json.RawMessage, total *int) error {
|
||||
switch field {
|
||||
case "summary", "precipitation_timing":
|
||||
return validateGeneratedTextString(name, field, raw, maxGeneratedTextStringCharacters, total)
|
||||
case "forecast_discussion":
|
||||
if name == "hourly" {
|
||||
return validateGeneratedTextString(name, field, raw, maxHourlyDiscussionCharacters, total)
|
||||
}
|
||||
return validateDayStyleDiscussion(name, raw, total)
|
||||
default:
|
||||
return fmt.Errorf("decode %s generated text: unsupported field", name)
|
||||
}
|
||||
}
|
||||
|
||||
func validateGeneratedTextString(name, field string, raw json.RawMessage, limit int, total *int) error {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return fmt.Errorf("%s generated text %s must be a string", name, generatedTextFieldLabel(field))
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("%s generated text %s must be a string", name, generatedTextFieldLabel(field))
|
||||
}
|
||||
characters := utf8.RuneCountInString(value)
|
||||
if characters > limit {
|
||||
return fmt.Errorf("%s generated text %s exceeds the %d-character limit", name, generatedTextFieldLabel(field), limit)
|
||||
}
|
||||
return addGeneratedTextCharacters(name, characters, total)
|
||||
}
|
||||
|
||||
func validateDayStyleDiscussion(name string, raw json.RawMessage, total *int) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s generated text forecast discussion must be an array", name)
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return fmt.Errorf("%s generated text forecast discussion must be an array", name)
|
||||
}
|
||||
|
||||
paragraphs := 0
|
||||
for decoder.More() {
|
||||
paragraphs++
|
||||
if paragraphs > maxDayStyleDiscussionParagraphs {
|
||||
return fmt.Errorf("%s generated text forecast discussion exceeds the %d-paragraph limit", name, maxDayStyleDiscussionParagraphs)
|
||||
}
|
||||
var rawParagraph json.RawMessage
|
||||
if err := decoder.Decode(&rawParagraph); err != nil || bytes.Equal(bytes.TrimSpace(rawParagraph), []byte("null")) {
|
||||
return fmt.Errorf("%s generated text forecast discussion paragraphs must be strings", name)
|
||||
}
|
||||
var paragraph string
|
||||
if err := json.Unmarshal(rawParagraph, ¶graph); err != nil {
|
||||
return fmt.Errorf("%s generated text forecast discussion paragraphs must be strings", name)
|
||||
}
|
||||
characters := utf8.RuneCountInString(paragraph)
|
||||
if characters > maxDayStyleDiscussionParagraphChars {
|
||||
return fmt.Errorf("%s generated text forecast discussion paragraphs exceed the %d-character limit", name, maxDayStyleDiscussionParagraphChars)
|
||||
}
|
||||
if err := addGeneratedTextCharacters(name, characters, total); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
return fmt.Errorf("%s generated text forecast discussion must be an array", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addGeneratedTextCharacters(name string, characters int, total *int) error {
|
||||
*total += characters
|
||||
if *total > maxGeneratedTextCharacters {
|
||||
return fmt.Errorf("%s generated text exceeds the %d-character limit", name, maxGeneratedTextCharacters)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatedTextFieldLabel(field string) string {
|
||||
if field == "forecast_discussion" {
|
||||
return "forecast discussion"
|
||||
}
|
||||
if field == "precipitation_timing" {
|
||||
return "precipitation timing"
|
||||
}
|
||||
return field
|
||||
}
|
||||
Reference in New Issue
Block a user