Enforce generated text report identity

This commit is contained in:
2026-08-13 01:36:56 +00:00
parent 44af91cadf
commit f8beed04cf
7 changed files with 251 additions and 17 deletions

View File

@@ -9,9 +9,10 @@ maintainer-facing context fields belong to [report templates](../templates.md).
## Catalog and validation ## Catalog and validation
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` rejects unknown schema or template IDs and generated text. `LookupDefinition` requires the exact report, schema, and
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed template triple and rejects unknown IDs, unsupported pairs, and a pair that
value and canonical normalized JSON, loads its canonical schema through 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
`internal/promptassets`, builds a render context, and renders through `internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`. `internal/reporttemplate`.
@@ -19,8 +20,9 @@ Daily, Today, and Tomorrow use a day-style value with required trimmed summary
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
and a single trimmed discussion string. Every form also requires the and a single trimmed discussion string. Every form also requires the
`precipitation_timing` field; an empty string means there is no supported timing `precipitation_timing` field; an empty string means there is no supported timing
prose to render. Typed decoding rejects missing required fields and unknown JSON prose to render. Typed decoding requires the exact lowercase JSON field names,
fields; no general-purpose JSON Schema engine is used at runtime. rejects missing, duplicate, case-variant, and unknown fields, and checks field
shapes; no general-purpose JSON Schema engine is used at runtime.
## Render contexts ## Render contexts

View File

@@ -20,8 +20,9 @@ source:
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` | | Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
The matching schemas and Promptkit definitions are embedded by The matching schemas and Promptkit definitions are embedded by
`internal/promptassets`. The generated-text catalog pairs each schema ID with `internal/promptassets`. The generated-text catalog requires each report's
its template ID; keep the matching prompt definition aligned with that pair. exact schema/template pair; keep the matching prompt definition aligned with
that report-specific triple.
Shared partials are under `internal/reporttemplate/templates/partials/`: Shared partials are under `internal/reporttemplate/templates/partials/`:

2
go.mod
View File

@@ -7,10 +7,10 @@ require gopkg.in/yaml.v3 v3.0.1
require ( require (
gitea.maximumdirect.net/eric/distributor v0.5.0 gitea.maximumdirect.net/eric/distributor v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.5.0 gitea.maximumdirect.net/eric/promptkit v0.5.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/sys v0.45.0 golang.org/x/sys v0.45.0
) )
require ( require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
) )

View File

@@ -28,6 +28,7 @@ type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error) type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
type catalogEntry struct { type catalogEntry struct {
reportID report.ID
schemaID string schemaID string
templateID string templateID string
validate validator validate validator
@@ -44,24 +45,28 @@ type Handler struct {
var catalog = []catalogEntry{ var catalog = []catalogEntry{
{ {
reportID: report.Hourly,
schemaID: schemaIDHourly, schemaID: schemaIDHourly,
templateID: templateIDHourly, templateID: templateIDHourly,
validate: validateHourly, validate: validateHourly,
renderContextBuilder: buildHourlyContext, renderContextBuilder: buildHourlyContext,
}, },
{ {
reportID: report.Daily,
schemaID: schemaIDDaily, schemaID: schemaIDDaily,
templateID: templateIDDaily, templateID: templateIDDaily,
validate: validateDaily, validate: validateDaily,
renderContextBuilder: buildDailyContext, renderContextBuilder: buildDailyContext,
}, },
{ {
reportID: report.Today,
schemaID: schemaIDToday, schemaID: schemaIDToday,
templateID: templateIDToday, templateID: templateIDToday,
validate: validateToday, validate: validateToday,
renderContextBuilder: buildTodayContext, renderContextBuilder: buildTodayContext,
}, },
{ {
reportID: report.Tomorrow,
schemaID: schemaIDTomorrow, schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow, templateID: templateIDTomorrow,
validate: validateTomorrow, validate: validateTomorrow,
@@ -70,8 +75,7 @@ var catalog = []catalogEntry{
} }
func LookupDefinition(definition report.Definition) (Handler, error) { func LookupDefinition(definition report.Definition) (Handler, error) {
var schemaKnown, templateKnown, pairKnown bool
var schemaKnown, templateKnown bool
for _, entry := range catalog { for _, entry := range catalog {
if entry.schemaID == definition.GeneratedTextSchemaID { if entry.schemaID == definition.GeneratedTextSchemaID {
schemaKnown = true schemaKnown = true
@@ -80,8 +84,12 @@ func LookupDefinition(definition report.Definition) (Handler, error) {
templateKnown = true templateKnown = true
} }
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID { if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
pairKnown = true
if entry.reportID != definition.ID {
continue
}
return Handler{ return Handler{
reportID: definition.ID, reportID: entry.reportID,
schemaID: entry.schemaID, schemaID: entry.schemaID,
templateID: entry.templateID, templateID: entry.templateID,
validate: entry.validate, validate: entry.validate,
@@ -95,6 +103,9 @@ func LookupDefinition(definition report.Definition) (Handler, error) {
if !templateKnown { if !templateKnown {
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID) return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
} }
if pairKnown {
return Handler{}, fmt.Errorf("generated text schema %q and report template %q do not belong to report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
}
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID) return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
} }

View File

@@ -8,17 +8,32 @@ import (
) )
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) { func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
for _, definition := range report.DefaultRegistry().All() { expected := []struct {
t.Run(string(definition.ID), func(t *testing.T) { reportID report.ID
schemaID string
templateID string
}{
{reportID: report.Daily, schemaID: "daily", templateID: "daily"},
{reportID: report.Today, schemaID: "today", templateID: "today"},
{reportID: report.Tomorrow, schemaID: "tomorrow", templateID: "tomorrow"},
{reportID: report.Hourly, schemaID: "hourly", templateID: "hourly"},
}
registry := report.DefaultRegistry()
for _, expected := range expected {
t.Run(string(expected.reportID), func(t *testing.T) {
definition := registry.MustLookup(expected.reportID)
if definition.GeneratedTextSchemaID != expected.schemaID || definition.TemplateID != expected.templateID {
t.Fatalf("definition = %#v, want schema/template %q/%q", definition, expected.schemaID, expected.templateID)
}
handler, err := LookupDefinition(definition) handler, err := LookupDefinition(definition)
if err != nil { if err != nil {
t.Fatalf("LookupDefinition() error = %v", err) t.Fatalf("LookupDefinition() error = %v", err)
} }
if handler.SchemaID() != definition.GeneratedTextSchemaID { if handler.SchemaID() != expected.schemaID {
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID) t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), expected.schemaID)
} }
if handler.TemplateID() != definition.TemplateID { if handler.TemplateID() != expected.templateID {
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID) t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), expected.templateID)
} }
if handler.validate == nil { if handler.validate == nil {
t.Fatal("validator is nil") t.Fatal("validator is nil")
@@ -40,6 +55,35 @@ func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
} }
} }
func TestCatalogRejectsKnownPairForAnotherReport(t *testing.T) {
pairs := []struct {
reportID report.ID
schemaID string
templateID string
}{
{reportID: report.Daily, schemaID: "daily", templateID: "daily"},
{reportID: report.Today, schemaID: "today", templateID: "today"},
{reportID: report.Tomorrow, schemaID: "tomorrow", templateID: "tomorrow"},
{reportID: report.Hourly, schemaID: "hourly", templateID: "hourly"},
}
registry := report.DefaultRegistry()
for _, definitionPair := range pairs {
for _, mismatchedPair := range pairs {
if definitionPair.reportID == mismatchedPair.reportID {
continue
}
t.Run(string(definitionPair.reportID)+"/"+string(mismatchedPair.reportID), func(t *testing.T) {
definition := registry.MustLookup(definitionPair.reportID)
definition.GeneratedTextSchemaID = mismatchedPair.schemaID
definition.TemplateID = mismatchedPair.templateID
if _, err := LookupDefinition(definition); err == nil || !strings.Contains(err.Error(), "do not belong") {
t.Fatalf("LookupDefinition(%#v) error = %v, want wrong-report error", definition, err)
}
})
}
}
}
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) { func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -8,8 +8,17 @@ import (
"strings" "strings"
) )
var generatedTextFieldNames = map[string]struct{}{
"summary": {},
"forecast_discussion": {},
"precipitation_timing": {},
}
func decodeGeneratedText[T any](data []byte, name string) (T, error) { func decodeGeneratedText[T any](data []byte, name string) (T, error) {
var value T var value T
if err := validateGeneratedTextObject(data, name); err != nil {
return value, err
}
decoder := json.NewDecoder(bytes.NewReader(data)) decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil { if err := decoder.Decode(&value); err != nil {
@@ -25,6 +34,43 @@ func decodeGeneratedText[T any](data []byte, name string) (T, error) {
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name) return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
} }
func validateGeneratedTextObject(data []byte, name string) error {
decoder := json.NewDecoder(bytes.NewReader(data))
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("decode %s generated text: %w", name, err)
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return fmt.Errorf("decode %s generated text: expected JSON object", name)
}
seen := make(map[string]struct{}, len(generatedTextFieldNames))
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("decode %s generated text: %w", name, err)
}
field, ok := token.(string)
if !ok {
return fmt.Errorf("decode %s generated text: expected object field name", name)
}
if _, ok := generatedTextFieldNames[field]; !ok {
return fmt.Errorf("decode %s generated text: json: unknown field %q", name, field)
}
if _, ok := seen[field]; ok {
return fmt.Errorf("decode %s generated text: duplicate field %q", name, field)
}
seen[field] = struct{}{}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return fmt.Errorf("decode %s generated text: %w", name, err)
}
}
if _, err := decoder.Token(); err != nil {
return fmt.Errorf("decode %s generated text: %w", name, err)
}
return nil
}
func requireGeneratedTextStringField(data []byte, name, field string) error { func requireGeneratedTextStringField(data []byte, name, field string) error {
var fields map[string]json.RawMessage var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil { if err := json.Unmarshal(data, &fields); err != nil {

View File

@@ -0,0 +1,130 @@
package generatedtext
import (
"bytes"
"fmt"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestGeneratedTextValidatorsRequireExactObjectFields(t *testing.T) {
validators := []struct {
name string
valid string
validate func([]byte) error
}{
{name: "daily", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateDailyJSON},
{name: "today", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTodayJSON},
{name: "tomorrow", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTomorrowJSON},
{name: "hourly", valid: `{"summary":"Summary","forecast_discussion":"Discussion","precipitation_timing":""}`, validate: validateHourlyJSON},
}
for _, validator := range validators {
t.Run(validator.name, func(t *testing.T) {
for _, test := range []struct {
name string
input string
valid bool
}{
{name: "canonical", input: validator.valid, valid: true},
{name: "missing", input: strings.Replace(validator.valid, `"summary":"Summary",`, "", 1)},
{name: "additional", input: strings.Replace(validator.valid, "}", `,"extra":"value"}`, 1)},
{name: "case variant summary", input: strings.Replace(validator.valid, `"summary"`, `"Summary"`, 1)},
{name: "case variant discussion", input: strings.Replace(validator.valid, `"forecast_discussion"`, `"Forecast_Discussion"`, 1)},
{name: "duplicate", input: strings.Replace(validator.valid, `"summary":"Summary",`, `"summary":"Summary","summary":"Other",`, 1)},
} {
t.Run(test.name, func(t *testing.T) {
err := validator.validate([]byte(test.input))
if (err == nil) != test.valid {
t.Fatalf("validate(%s) error = %v, want valid = %t", test.input, err, test.valid)
}
})
}
})
}
}
func TestTypedValidatorsMatchEmbeddedSchemaObjectShape(t *testing.T) {
validators := []struct {
name string
valid string
validate func([]byte) error
}{
{name: "daily", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateDailyJSON},
{name: "today", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTodayJSON},
{name: "tomorrow", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTomorrowJSON},
{name: "hourly", valid: `{"summary":"Summary","forecast_discussion":"Discussion","precipitation_timing":""}`, validate: validateHourlyJSON},
}
for _, validator := range validators {
t.Run(validator.name, func(t *testing.T) {
schema := generatedTextSchema(t, validator.name)
for _, test := range []struct {
name string
input string
}{
{name: "canonical", input: validator.valid},
{name: "missing", input: strings.Replace(validator.valid, `"summary":"Summary",`, "", 1)},
{name: "additional", input: strings.Replace(validator.valid, "}", `,"extra":"value"}`, 1)},
{name: "case variant", input: strings.Replace(validator.valid, `"summary"`, `"Summary"`, 1)},
{name: "null", input: strings.Replace(validator.valid, `"precipitation_timing":""`, `"precipitation_timing":null`, 1)},
{name: "wrong type", input: strings.Replace(validator.valid, `"summary":"Summary"`, `"summary":false`, 1)},
} {
t.Run(test.name, func(t *testing.T) {
instance, err := jsonschema.UnmarshalJSON(strings.NewReader(test.input))
if err != nil {
t.Fatalf("parse schema instance: %v", err)
}
schemaValid := schema.Validate(instance) == nil
typedValid := validator.validate([]byte(test.input)) == nil
if schemaValid != typedValid {
t.Fatalf("schema valid = %t, typed valid = %t for %s", schemaValid, typedValid, test.input)
}
})
}
})
}
}
func generatedTextSchema(t *testing.T, id string) *jsonschema.Schema {
t.Helper()
data, err := promptassets.Schema(id)
if err != nil {
t.Fatalf("Schema(%q) error = %v", id, err)
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
if err != nil {
t.Fatalf("parse schema %q: %v", id, err)
}
compiler := jsonschema.NewCompiler()
location := fmt.Sprintf("https://weatherreporter.example/schemas/%s.json", id)
if err := compiler.AddResource(location, document); err != nil {
t.Fatalf("add schema %q: %v", id, err)
}
schema, err := compiler.Compile(location)
if err != nil {
t.Fatalf("compile schema %q: %v", id, err)
}
return schema
}
func validateDailyJSON(data []byte) error {
_, _, err := ValidateDaily(data)
return err
}
func validateTodayJSON(data []byte) error {
_, _, err := ValidateToday(data)
return err
}
func validateTomorrowJSON(data []byte) error {
_, _, err := ValidateTomorrow(data)
return err
}
func validateHourlyJSON(data []byte) error {
_, _, err := ValidateHourly(data)
return err
}