Require precipitation timing in generated text

This commit is contained in:
2026-08-01 01:25:57 +00:00
parent 2dbba36bf0
commit 8c19ad763b
40 changed files with 156 additions and 134 deletions

View File

@@ -3,7 +3,7 @@
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are
`weather.daily_generated_text`, `weather.today_generated_text`, `weather.daily_generated_text`, `weather.today_generated_text`,
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version `weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version
`1.0.0`. Their prompt assets and generated-text JSON Schemas are embedded by `1.0.1`. Their prompt assets and generated-text JSON Schemas are embedded by
`internal/promptassets`. `internal/promptassets`.
Before collection, Weatherreporter inspects the exact prompt version, requires one required Before collection, Weatherreporter inspects the exact prompt version, requires one required
@@ -17,6 +17,10 @@ JSON that Weatherreporter validates before rendering its own Markdown template.
execution receipts are project-owned, safe provenance records. Content-rich diagnostics are execution receipts are project-owned, safe provenance records. Content-rich diagnostics are
opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions. opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
The generated-text schemas require `summary`, `forecast_discussion`, and
`precipitation_timing`, and reject additional properties. Prompts return an empty string for
`precipitation_timing` when the deterministic package contains no precipitation windows.
Prompt/profile configuration is owned by the [configuration reference](../config.md). Adapter Prompt/profile configuration is owned by the [configuration reference](../config.md). Adapter
construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md). construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
Durable metadata compatibility is described in [state internals](../internal/state.md). Durable metadata compatibility is described in [state internals](../internal/state.md).

View File

@@ -17,8 +17,9 @@ value and canonical normalized JSON, loads its canonical schema through
Daily, Today, and Tomorrow use a day-style value with required trimmed summary 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. Each form permits optional trimmed and a single trimmed discussion string. Every form also requires the
precipitation-timing and confidence prose. Typed decoding rejects unknown JSON `precipitation_timing` field; an empty string means there is no supported timing
prose to render. Typed decoding rejects missing required fields and unknown JSON
fields; no general-purpose JSON Schema engine is used at runtime. fields; no general-purpose JSON Schema engine is used at runtime.
## Render contexts ## Render contexts

View File

@@ -18,10 +18,10 @@ period and run metadata for one invocation.
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy | | Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` | | `daily` | `1.0.1` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` | | `today` | `1.0.1` | Selected or current local civil day | Same valid date | Morning | `today.md` |
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` | | `tomorrow` | `1.0.1` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `hourly.md` | | `hourly` | `1.0.1` | Rolling six-hour interval | Rolling window | — | `hourly.md` |
Each report pairs its ID and prompt version with matching template and schema Each report pairs its ID and prompt version with matching template and schema
IDs. Exact template fields and schema assets belong to [report templates](../templates.md) IDs. Exact template fields and schema assets belong to [report templates](../templates.md)

View File

@@ -128,8 +128,7 @@ It is not a source for deterministic weather facts.
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required. | | `.GeneratedText.Summary` | `string` | `string` | Required. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. | | `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Optional prose used by the precipitation partial when deterministic windows exist. | | `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
| `.GeneratedText.Confidence` | `string` | `string` | Optional validated prose; the current templates do not render it. |
The JSON schema rejects unknown properties and defines the required fields, but The JSON schema rejects unknown properties and defines the required fields, but
the schema body and validation behavior are documented in [Generated Text the schema body and validation behavior are documented in [Generated Text

View File

@@ -68,11 +68,11 @@ func (client *fakeClient) request() promptkit.GenerateRequest {
func TestInspectPromptAndProfile(t *testing.T) { func TestInspectPromptAndProfile(t *testing.T) {
adapter := newTestAdapter(t, &fakeClient{}) adapter := newTestAdapter(t, &fakeClient{})
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0") inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.1")
if err != nil { if err != nil {
t.Fatalf("InspectPrompt() error = %v", err) t.Fatalf("InspectPrompt() error = %v", err)
} }
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" { if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.1" || inspection.DefaultProfileID != "gemini-flash-latest" {
t.Fatalf("inspection = %#v", inspection) t.Fatalf("inspection = %#v", inspection)
} }
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" { if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
@@ -369,7 +369,7 @@ func testProfileDirectory(t *testing.T, profile string) string {
func testExecuteRequest() promptexec.ExecuteRequest { func testExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{ return promptexec.ExecuteRequest{
PromptID: "weather.daily_generated_text", PromptID: "weather.daily_generated_text",
PromptVersion: "1.0.0", PromptVersion: "1.0.1",
ProfileID: "test-profile", ProfileID: "test-profile",
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"), DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
DataPackagePath: "data-packages/daily/data_package.yaml", DataPackagePath: "data-packages/daily/data_package.yaml",
@@ -378,7 +378,7 @@ func testExecuteRequest() promptexec.ExecuteRequest {
func validResponse() *promptkit.GenerateResponse { func validResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{ return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`, Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20}, Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
} }
} }

View File

@@ -119,7 +119,7 @@ func (e artifactPathExecutor) Execute(_ context.Context, req promptexec.ExecuteR
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash",
StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath, StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath,
RawOutput: []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`), RawOutput: []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`),
Validation: promptexec.NewValidation(validation, "json_schema", "daily.generated_text.schema.json", nil), Validation: promptexec.NewValidation(validation, "json_schema", "daily.generated_text.schema.json", nil),
}, nil }, nil
} }

View File

@@ -643,7 +643,7 @@ func workflowTime(value string) time.Time {
} }
func validHourlyWorkflowJSON() string { func validHourlyWorkflowJSON() string {
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region."}`
} }
func validTomorrowWorkflowJSON() string { func validTomorrowWorkflowJSON() string {
@@ -655,5 +655,5 @@ func validTodayWorkflowJSON() string {
} }
func validDailyWorkflowJSON() string { func validDailyWorkflowJSON() string {
return `{"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.","confidence":"Medium"}` return `{"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."}`
} }

View File

@@ -147,7 +147,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
} }
hourly, normalized, err := hourlyHandler.Validate([]byte(`{ hourly, normalized, 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": ""
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("Validate(hourly) error = %v", err) t.Fatalf("Validate(hourly) error = %v", err)
@@ -165,7 +166,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
} }
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{ tomorrow, normalized, 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": ""
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("Validate(tomorrow) error = %v", err) t.Fatalf("Validate(tomorrow) error = %v", err)
@@ -187,7 +189,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
} }
today, normalized, err := todayHandler.Validate([]byte(`{ today, normalized, 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": ""
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("Validate(today) error = %v", err) t.Fatalf("Validate(today) error = %v", err)
@@ -209,7 +212,8 @@ func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
} }
daily, normalized, err := dailyHandler.Validate([]byte(`{ daily, normalized, 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": ""
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("Validate(daily) error = %v", err) t.Fatalf("Validate(daily) error = %v", err)

View File

@@ -3,8 +3,7 @@ package generatedtext
type Daily struct { type Daily struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"` PrecipitationTiming string `json:"precipitation_timing"`
Confidence string `json:"confidence,omitempty"`
} }
func ValidateDaily(data []byte) (Daily, []byte, error) { func ValidateDaily(data []byte) (Daily, []byte, error) {
@@ -16,7 +15,6 @@ func (d *Daily) dayStyleFields() dayStyleFields {
Summary: d.Summary, Summary: d.Summary,
ForecastDiscussion: d.ForecastDiscussion, ForecastDiscussion: d.ForecastDiscussion,
PrecipitationTiming: d.PrecipitationTiming, PrecipitationTiming: d.PrecipitationTiming,
Confidence: d.Confidence,
} }
} }
@@ -24,5 +22,4 @@ func (d *Daily) setDayStyleFields(fields dayStyleFields) {
d.Summary = fields.Summary d.Summary = fields.Summary
d.ForecastDiscussion = fields.ForecastDiscussion d.ForecastDiscussion = fields.ForecastDiscussion
d.PrecipitationTiming = fields.PrecipitationTiming d.PrecipitationTiming = fields.PrecipitationTiming
d.Confidence = fields.Confidence
} }

View File

@@ -13,8 +13,7 @@ func TestValidateDailyNormalizesJSON(t *testing.T) {
"", "",
" Temperatures stay seasonable by afternoon. " " Temperatures stay seasonable by afternoon. "
], ],
"precipitation_timing": " Rain is most likely during the afternoon. ", "precipitation_timing": " Rain is most likely during the afternoon. "
"confidence": " Medium "
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("ValidateDaily() error = %v", err) t.Fatalf("ValidateDaily() error = %v", err)
@@ -28,23 +27,22 @@ 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.","confidence":"Medium"}` 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 { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
} }
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) { func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
_, normalized, err := ValidateDaily([]byte(`{ _, normalized, 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": " "
"confidence": " "
}`)) }`))
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."]}` 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 { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }

View File

@@ -9,7 +9,6 @@ type dayStyleFields struct {
Summary string Summary string
ForecastDiscussion []string ForecastDiscussion []string
PrecipitationTiming string PrecipitationTiming string
Confidence string
} }
type dayStyleGeneratedText interface { type dayStyleGeneratedText interface {
@@ -31,7 +30,6 @@ func validateDayStyleGeneratedText[T any, PT interface {
fields := pointer.dayStyleFields() fields := pointer.dayStyleFields()
fields.Summary = strings.TrimSpace(fields.Summary) fields.Summary = strings.TrimSpace(fields.Summary)
fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming) fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming)
fields.Confidence = strings.TrimSpace(fields.Confidence)
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion) fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
if fields.Summary == "" { if fields.Summary == "" {
var zero T var zero T
@@ -41,6 +39,10 @@ func validateDayStyleGeneratedText[T any, PT interface {
var zero T var zero T
return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name) return zero, nil, 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
}
pointer.setDayStyleFields(fields) pointer.setDayStyleFields(fields)
normalized, err := normalizeGeneratedText(value, name) normalized, err := normalizeGeneratedText(value, name)

View File

@@ -60,8 +60,7 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
"", "",
" Second paragraph. " " Second paragraph. "
], ],
"precipitation_timing": " Afternoon. ", "precipitation_timing": " Afternoon. "
"confidence": " Medium "
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("validate() error = %v", err) t.Fatalf("validate() error = %v", err)
@@ -76,31 +75,35 @@ 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)
} }
if fields.Confidence != "Medium" { want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon."}`
t.Fatalf("Confidence = %q, want trimmed confidence", fields.Confidence)
}
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon.","confidence":"Medium"}`
if string(normalized) != want { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
}) })
t.Run("omits empty optional fields", func(t *testing.T) { t.Run("preserves required empty precipitation timing", func(t *testing.T) {
_, normalized, err := report.validate([]byte(`{ _, normalized, err := report.validate([]byte(`{
"summary": "Shared summary.", "summary": "Shared summary.",
"forecast_discussion": ["First paragraph."], "forecast_discussion": ["First paragraph."],
"precipitation_timing": " ", "precipitation_timing": " "
"confidence": " "
}`)) }`))
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."]}` want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"precipitation_timing":""}`
if string(normalized) != want { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
}) })
t.Run("requires precipitation timing field", func(t *testing.T) {
_, _, 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)
}
})
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 {
@@ -110,6 +113,13 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
t.Fatalf("validate() error = %v, want unknown field error", err) t.Fatalf("validate() error = %v, want unknown field error", err)
} }
}) })
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"}`))
if err == nil || !strings.Contains(err.Error(), `unknown field "confidence"`) {
t.Fatalf("validate() error = %v, want retired confidence field rejection", err)
}
})
}) })
} }
} }

View File

@@ -9,8 +9,7 @@ import (
type Hourly struct { type Hourly struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion string `json:"forecast_discussion"` ForecastDiscussion string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"` PrecipitationTiming string `json:"precipitation_timing"`
Confidence string `json:"confidence,omitempty"`
} }
func ValidateHourly(data []byte) (Hourly, []byte, error) { func ValidateHourly(data []byte) (Hourly, []byte, error) {
@@ -22,13 +21,15 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) {
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)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" { if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required") return Hourly{}, nil, 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{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
} }
if err := requireGeneratedTextStringField(data, "hourly", "precipitation_timing"); err != nil {
return Hourly{}, nil, err
}
normalized, err := normalizeGeneratedText(value, "hourly") normalized, err := normalizeGeneratedText(value, "hourly")
if err != nil { if err != nil {

View File

@@ -9,8 +9,7 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateHourly([]byte(`{ value, normalized, 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. "
"confidence": " Medium "
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("ValidateHourly() error = %v", err) t.Fatalf("ValidateHourly() error = %v", err)
@@ -24,23 +23,22 @@ 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.","confidence":"Medium"}` 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 { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
} }
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) { func TestValidateHourlyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
_, normalized, err := ValidateHourly([]byte(`{ _, normalized, 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": " "
"confidence": " "
}`)) }`))
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."}` want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":""}`
if string(normalized) != want { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
@@ -72,6 +70,21 @@ func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`, in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`,
want: "forecast discussion is required", want: "forecast discussion is required",
}, },
{
name: "missing precipitation timing",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`,
want: "precipitation timing is required",
},
{
name: "null precipitation timing",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":null}`,
want: "precipitation timing must be a string",
},
{
name: "retired confidence field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"","confidence":"Medium"}`,
want: `unknown field "confidence"`,
},
{ {
name: "old timing field rejected", name: "old timing field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`, in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`,

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"strings"
) )
func decodeGeneratedText[T any](data []byte, name string) (T, error) { func decodeGeneratedText[T any](data []byte, name string) (T, error) {
@@ -24,6 +25,21 @@ 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 requireGeneratedTextStringField(data []byte, name, field string) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return fmt.Errorf("decode %s generated text: %w", name, err)
}
raw, ok := fields[field]
if !ok {
return fmt.Errorf("%s generated text %s is required", name, strings.ReplaceAll(field, "_", " "))
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return fmt.Errorf("%s generated text %s must be a string", name, strings.ReplaceAll(field, "_", " "))
}
return nil
}
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) { func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
normalized, err := json.Marshal(value) normalized, err := json.Marshal(value)
if err != nil { if err != nil {

View File

@@ -21,7 +21,6 @@ func TestBuildHourlyRenderContext(t *testing.T) {
Summary: "Storm chances increase through late morning.", Summary: "Storm chances increase through late morning.",
ForecastDiscussion: "A front will keep the region unsettled.", ForecastDiscussion: "A front will keep the region unsettled.",
PrecipitationTiming: "A cold front is moving into the region.", PrecipitationTiming: "A cold front is moving into the region.",
Confidence: "Medium confidence in timing.",
} }
collected := testCollected() collected := testCollected()
derived := testDerived() derived := testDerived()

View File

@@ -3,8 +3,7 @@ package generatedtext
type Today struct { type Today struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"` PrecipitationTiming string `json:"precipitation_timing"`
Confidence string `json:"confidence,omitempty"`
} }
func ValidateToday(data []byte) (Today, []byte, error) { func ValidateToday(data []byte) (Today, []byte, error) {
@@ -16,7 +15,6 @@ func (t *Today) dayStyleFields() dayStyleFields {
Summary: t.Summary, Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion, ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming, PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
} }
} }
@@ -24,5 +22,4 @@ func (t *Today) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
} }

View File

@@ -13,8 +13,7 @@ func TestValidateTodayNormalizesJSON(t *testing.T) {
"", "",
" Temperatures stay mild through the afternoon. " " Temperatures stay mild through the afternoon. "
], ],
"precipitation_timing": " Rain is most likely during the afternoon. ", "precipitation_timing": " Rain is most likely during the afternoon. "
"confidence": " Medium "
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("ValidateToday() error = %v", err) t.Fatalf("ValidateToday() error = %v", err)
@@ -28,23 +27,22 @@ 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.","confidence":"Medium"}` 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 { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
} }
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) { func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
_, normalized, err := ValidateToday([]byte(`{ _, normalized, 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": " "
"confidence": " "
}`)) }`))
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."]}` want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"precipitation_timing":""}`
if string(normalized) != want { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }

View File

@@ -3,8 +3,7 @@ package generatedtext
type Tomorrow struct { type Tomorrow struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"` PrecipitationTiming string `json:"precipitation_timing"`
Confidence string `json:"confidence,omitempty"`
} }
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
@@ -16,7 +15,6 @@ func (t *Tomorrow) dayStyleFields() dayStyleFields {
Summary: t.Summary, Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion, ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming, PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
} }
} }
@@ -24,5 +22,4 @@ func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
} }

View File

@@ -13,8 +13,7 @@ func TestValidateTomorrowNormalizesJSON(t *testing.T) {
"", "",
" Temperatures stay seasonable by afternoon. " " Temperatures stay seasonable by afternoon. "
], ],
"precipitation_timing": " Rain is most likely before sunrise. ", "precipitation_timing": " Rain is most likely before sunrise. "
"confidence": " Medium "
}`)) }`))
if err != nil { if err != nil {
t.Fatalf("ValidateTomorrow() error = %v", err) t.Fatalf("ValidateTomorrow() error = %v", err)
@@ -28,23 +27,22 @@ 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.","confidence":"Medium"}` 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 { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }
} }
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) { func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T) {
_, normalized, err := ValidateTomorrow([]byte(`{ _, normalized, 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": " "
"confidence": " "
}`)) }`))
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."]}` want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"precipitation_timing":""}`
if string(normalized) != want { if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want) t.Fatalf("normalized = %s, want %s", normalized, want)
} }

View File

@@ -8,8 +8,7 @@ Return these fields:
- `summary`: required. One or two sentences summarizing the main weather story for the valid period. - `summary`: required. One or two sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period. - `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows. - `precipitation_timing`: required. When the deterministic `precip_timing` module contains precipitation windows, provide the supported timing prose described below. Otherwise, return an empty string (`""`).
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only. Return JSON only.
@@ -27,7 +26,7 @@ In most cases, include three paragraphs: a two-to-four sentence relevant local o
# Precipitation timing # Precipitation timing
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When precipitation windows are present, use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When no precipitation windows are present, return an empty string (`""`) for `precipitation_timing`.
# Narrative source selection # Narrative source selection

View File

@@ -1,5 +1,5 @@
id: weather.daily_generated_text id: weather.daily_generated_text
version: "1.0.0" version: "1.0.1"
default_profile: gemini-flash-latest default_profile: gemini-flash-latest
description: Daily weather report analysis prompt. description: Daily weather report analysis prompt.
inputs: inputs:

View File

@@ -8,8 +8,7 @@ Return these fields:
- `summary`: required. One or two sentences summarizing the main weather story for the valid period. - `summary`: required. One or two sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. Two or three sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period. - `forecast_discussion`: required. Two or three sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows. - `precipitation_timing`: required. When the deterministic `precip_timing` module contains precipitation windows, provide the supported timing prose described below. Otherwise, return an empty string (`""`).
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only. Return JSON only.
@@ -25,4 +24,4 @@ Use narrative products to explain the “why” behind the local forecast when u
# Precipitation timing # Precipitation timing
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When precipitation windows are present, use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When no precipitation windows are present, return an empty string (`""`) for `precipitation_timing`.

View File

@@ -1,5 +1,5 @@
id: weather.hourly_generated_text id: weather.hourly_generated_text
version: "1.0.0" version: "1.0.1"
default_profile: gemini-flash-latest default_profile: gemini-flash-latest
description: Hourly weather report analysis prompt. description: Hourly weather report analysis prompt.
inputs: inputs:

View File

@@ -8,8 +8,7 @@ Return these fields:
- `summary`: required. One or two sentences summarizing the main weather story for the valid period. - `summary`: required. One or two sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period. - `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows. - `precipitation_timing`: required. When the deterministic `precip_timing` module contains precipitation windows, provide the supported timing prose described below. Otherwise, return an empty string (`""`).
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only. Return JSON only.
@@ -27,4 +26,4 @@ In most cases, include three paragraphs: a two-to-four sentence relevant local o
# Precipitation timing # Precipitation timing
Include this only if precipitation is forecast. Use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When precipitation windows are present, use one to four sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When no precipitation windows are present, return an empty string (`""`) for `precipitation_timing`.

View File

@@ -1,5 +1,5 @@
id: weather.today_generated_text id: weather.today_generated_text
version: "1.0.0" version: "1.0.1"
default_profile: gemini-flash-latest default_profile: gemini-flash-latest
description: Today's weather report analysis prompt. description: Today's weather report analysis prompt.
inputs: inputs:

View File

@@ -8,8 +8,7 @@ Return these fields:
- `summary`: required. One or two sentences summarizing the main weather story for the valid period. - `summary`: required. One or two sentences summarizing the main weather story for the valid period.
- `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period. - `forecast_discussion`: required. Three paragraphs explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows. - `precipitation_timing`: required. When the deterministic `precip_timing` module contains precipitation windows, provide the supported timing prose described below. Otherwise, return an empty string (`""`).
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only. Return JSON only.
@@ -27,4 +26,4 @@ In most cases, include three paragraphs: a two-to-four sentence relevant local o
# Precipitation timing # Precipitation timing
Use one or two sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When precipitation windows are present, use one or two sentences to give practical context about a supported frontal, convective, or stratiform setup; expected type, intensity, and duration; and uncertainty in onset or duration. When no precipitation windows are present, return an empty string (`""`) for `precipitation_timing`.

View File

@@ -1,5 +1,5 @@
id: weather.tomorrow_generated_text id: weather.tomorrow_generated_text
version: "1.0.0" version: "1.0.1"
default_profile: gemini-flash-latest default_profile: gemini-flash-latest
description: Tomorrow's weather report analysis prompt. description: Tomorrow's weather report analysis prompt.
inputs: inputs:

View File

@@ -4,11 +4,10 @@
"title": "Daily GeneratedText", "title": "Daily GeneratedText",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["summary", "forecast_discussion"], "required": ["summary", "forecast_discussion", "precipitation_timing"],
"properties": { "properties": {
"summary": {"type": "string"}, "summary": {"type": "string"},
"forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"precipitation_timing": {"type": "string"}, "precipitation_timing": {"type": "string"}
"confidence": {"type": "string"}
} }
} }

View File

@@ -4,11 +4,10 @@
"title": "Hourly GeneratedText", "title": "Hourly GeneratedText",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["summary", "forecast_discussion"], "required": ["summary", "forecast_discussion", "precipitation_timing"],
"properties": { "properties": {
"summary": {"type": "string"}, "summary": {"type": "string"},
"forecast_discussion": {"type": "string"}, "forecast_discussion": {"type": "string"},
"precipitation_timing": {"type": "string"}, "precipitation_timing": {"type": "string"}
"confidence": {"type": "string"}
} }
} }

View File

@@ -4,11 +4,10 @@
"title": "Today GeneratedText", "title": "Today GeneratedText",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["summary", "forecast_discussion"], "required": ["summary", "forecast_discussion", "precipitation_timing"],
"properties": { "properties": {
"summary": {"type": "string"}, "summary": {"type": "string"},
"forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"precipitation_timing": {"type": "string"}, "precipitation_timing": {"type": "string"}
"confidence": {"type": "string"}
} }
} }

View File

@@ -4,11 +4,10 @@
"title": "Tomorrow GeneratedText", "title": "Tomorrow GeneratedText",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["summary", "forecast_discussion"], "required": ["summary", "forecast_discussion", "precipitation_timing"],
"properties": { "properties": {
"summary": {"type": "string"}, "summary": {"type": "string"},
"forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "forecast_discussion": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"precipitation_timing": {"type": "string"}, "precipitation_timing": {"type": "string"}
"confidence": {"type": "string"}
} }
} }

View File

@@ -67,8 +67,8 @@ func TestPromptAssetsDeclareTheFourGeneratedTextPrompts(t *testing.T) {
if err := yaml.Unmarshal(data, &definition); err != nil { if err := yaml.Unmarshal(data, &definition); err != nil {
t.Fatalf("decode prompt definition: %v", err) t.Fatalf("decode prompt definition: %v", err)
} }
if definition.ID != tc.id || definition.Version != "1.0.0" || definition.DefaultProfile != "gemini-flash-latest" { if definition.ID != tc.id || definition.Version != "1.0.1" || definition.DefaultProfile != "gemini-flash-latest" {
t.Fatalf("definition = %#v, want %s version 1.0.0 and gemini-flash-latest", definition, tc.id) t.Fatalf("definition = %#v, want %s version 1.0.1 and gemini-flash-latest", definition, tc.id)
} }
if len(definition.Inputs) != 1 || definition.Inputs[0].Name != "data_package" || !definition.Inputs[0].Required || definition.Inputs[0].ContentType != "application/yaml" { if len(definition.Inputs) != 1 || definition.Inputs[0].Name != "data_package" || !definition.Inputs[0].Required || definition.Inputs[0].ContentType != "application/yaml" {
t.Fatalf("inputs = %#v, want one required YAML data_package", definition.Inputs) t.Fatalf("inputs = %#v, want one required YAML data_package", definition.Inputs)
@@ -101,11 +101,11 @@ func TestSchemasAreCanonicalAndIndependent(t *testing.T) {
if err := json.Unmarshal(data, &schema); err != nil { if err := json.Unmarshal(data, &schema); err != nil {
t.Fatalf("decode schema: %v", err) t.Fatalf("decode schema: %v", err)
} }
if schema.Type != "object" || schema.AdditionalProperties || strings.Join(schema.Required, ",") != "summary,forecast_discussion" { if schema.Type != "object" || schema.AdditionalProperties || strings.Join(schema.Required, ",") != "summary,forecast_discussion,precipitation_timing" {
t.Fatalf("schema = %#v, want strict generated-text object", schema) t.Fatalf("schema = %#v, want strict generated-text object", schema)
} }
if _, ok := schema.Properties["confidence"]; !ok { if _, ok := schema.Properties["confidence"]; ok {
t.Fatalf("schema properties = %#v, want confidence", schema.Properties) t.Fatalf("schema properties = %#v, do not want retired confidence field", schema.Properties)
} }
if id == "daily" && (schema.ID != "weatherreporter.daily.generated_text.schema.json" || schema.Title != "Daily GeneratedText") { if id == "daily" && (schema.ID != "weatherreporter.daily.generated_text.schema.json" || schema.Title != "Daily GeneratedText") {
t.Fatalf("daily schema identity = %q/%q, want corrected Daily identity", schema.ID, schema.Title) t.Fatalf("daily schema identity = %q/%q, want corrected Daily identity", schema.ID, schema.Title)
@@ -129,11 +129,11 @@ func TestPromptkitInspectsEmbeddedPromptsOffline(t *testing.T) {
} }
for _, id := range []string{"weather.daily_generated_text", "weather.today_generated_text", "weather.tomorrow_generated_text", "weather.hourly_generated_text"} { for _, id := range []string{"weather.daily_generated_text", "weather.today_generated_text", "weather.tomorrow_generated_text", "weather.hourly_generated_text"} {
t.Run(id, func(t *testing.T) { t.Run(id, func(t *testing.T) {
inspection, err := engine.InspectPrompt(context.Background(), id, "1.0.0") inspection, err := engine.InspectPrompt(context.Background(), id, "1.0.1")
if err != nil { if err != nil {
t.Fatalf("InspectPrompt() error = %v", err) t.Fatalf("InspectPrompt() error = %v", err)
} }
if inspection.PromptID != id || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" { if inspection.PromptID != id || inspection.PromptVersion != "1.0.1" || inspection.DefaultProfileID != "gemini-flash-latest" {
t.Fatalf("inspection = %#v", inspection) t.Fatalf("inspection = %#v", inspection)
} }
}) })

View File

@@ -12,7 +12,7 @@ func dailyDefinition() Definition {
ID: Daily, ID: Daily,
Name: "Daily Report", Name: "Daily Report",
PromptID: "weather.daily_generated_text", PromptID: "weather.daily_generated_text",
PromptVersion: "1.0.0", PromptVersion: "1.0.1",
TemplateID: "daily", TemplateID: "daily",
GeneratedTextSchemaID: "daily", GeneratedTextSchemaID: "daily",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,

View File

@@ -14,7 +14,7 @@ func hourlyDefinition() Definition {
ID: Hourly, ID: Hourly,
Name: "Hourly Report", Name: "Hourly Report",
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
PromptVersion: "1.0.0", PromptVersion: "1.0.1",
TemplateID: "hourly", TemplateID: "hourly",
GeneratedTextSchemaID: "hourly", GeneratedTextSchemaID: "hourly",
ComparisonStrategy: CompareRollingWindow, ComparisonStrategy: CompareRollingWindow,

View File

@@ -51,8 +51,8 @@ func TestRegistryContainsOnlyPromptBackedReports(t *testing.T) {
} }
for _, definition := range definitions { for _, definition := range definitions {
if definition.PromptVersion != "1.0.0" { if definition.PromptVersion != "1.0.1" {
t.Fatalf("%s PromptVersion = %q, want 1.0.0", definition.ID, definition.PromptVersion) t.Fatalf("%s PromptVersion = %q, want 1.0.1", definition.ID, definition.PromptVersion)
} }
if definition.TemplateID == "" || definition.GeneratedTextSchemaID == "" { if definition.TemplateID == "" || definition.GeneratedTextSchemaID == "" {
t.Fatalf("%s template/schema = %q/%q, want both set", definition.ID, definition.TemplateID, definition.GeneratedTextSchemaID) t.Fatalf("%s template/schema = %q/%q, want both set", definition.ID, definition.TemplateID, definition.GeneratedTextSchemaID)

View File

@@ -10,7 +10,7 @@ func todayDefinition() Definition {
ID: Today, ID: Today,
Name: "Today Report", Name: "Today Report",
PromptID: "weather.today_generated_text", PromptID: "weather.today_generated_text",
PromptVersion: "1.0.0", PromptVersion: "1.0.1",
TemplateID: "today", TemplateID: "today",
GeneratedTextSchemaID: "today", GeneratedTextSchemaID: "today",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,

View File

@@ -10,7 +10,7 @@ func tomorrowDefinition() Definition {
ID: Tomorrow, ID: Tomorrow,
Name: "Tomorrow Report", Name: "Tomorrow Report",
PromptID: "weather.tomorrow_generated_text", PromptID: "weather.tomorrow_generated_text",
PromptVersion: "1.0.0", PromptVersion: "1.0.1",
TemplateID: "tomorrow", TemplateID: "tomorrow",
GeneratedTextSchemaID: "tomorrow", GeneratedTextSchemaID: "tomorrow",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,

View File

@@ -64,7 +64,7 @@ func TestSchemaLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Schema() error = %v", err) t.Fatalf("Schema() error = %v", err)
} }
assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"}) assertStringSchema(t, data, "summary,forecast_discussion,precipitation_timing", []string{"summary", "forecast_discussion", "precipitation_timing"})
} }
func TestTomorrowSchemaLookup(t *testing.T) { func TestTomorrowSchemaLookup(t *testing.T) {
@@ -72,7 +72,7 @@ func TestTomorrowSchemaLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Schema() error = %v", err) t.Fatalf("Schema() error = %v", err)
} }
schema := assertSchema(t, data, "summary,forecast_discussion") schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing")
property, ok := schema.Properties["forecast_discussion"].(map[string]any) property, ok := schema.Properties["forecast_discussion"].(map[string]any)
if !ok { if !ok {
t.Fatal("schema property forecast_discussion missing or invalid") t.Fatal("schema property forecast_discussion missing or invalid")
@@ -87,7 +87,7 @@ func TestTomorrowSchemaLookup(t *testing.T) {
if !ok || items["type"] != "string" { if !ok || items["type"] != "string" {
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
} }
for _, field := range []string{"summary", "precipitation_timing", "confidence"} { for _, field := range []string{"summary", "precipitation_timing"} {
property, ok := schema.Properties[field].(map[string]any) property, ok := schema.Properties[field].(map[string]any)
if !ok { if !ok {
t.Fatalf("schema property %q missing or invalid", field) t.Fatalf("schema property %q missing or invalid", field)
@@ -103,7 +103,7 @@ func TestDailySchemaLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Schema() error = %v", err) t.Fatalf("Schema() error = %v", err)
} }
schema := assertSchema(t, data, "summary,forecast_discussion") schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing")
property, ok := schema.Properties["forecast_discussion"].(map[string]any) property, ok := schema.Properties["forecast_discussion"].(map[string]any)
if !ok { if !ok {
t.Fatal("schema property forecast_discussion missing or invalid") t.Fatal("schema property forecast_discussion missing or invalid")
@@ -118,7 +118,7 @@ func TestDailySchemaLookup(t *testing.T) {
if !ok || items["type"] != "string" { if !ok || items["type"] != "string" {
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
} }
for _, field := range []string{"summary", "precipitation_timing", "confidence"} { for _, field := range []string{"summary", "precipitation_timing"} {
property, ok := schema.Properties[field].(map[string]any) property, ok := schema.Properties[field].(map[string]any)
if !ok { if !ok {
t.Fatalf("schema property %q missing or invalid", field) t.Fatalf("schema property %q missing or invalid", field)
@@ -134,7 +134,7 @@ func TestTodaySchemaLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Schema() error = %v", err) t.Fatalf("Schema() error = %v", err)
} }
schema := assertSchema(t, data, "summary,forecast_discussion") schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing")
property, ok := schema.Properties["forecast_discussion"].(map[string]any) property, ok := schema.Properties["forecast_discussion"].(map[string]any)
if !ok { if !ok {
t.Fatal("schema property forecast_discussion missing or invalid") t.Fatal("schema property forecast_discussion missing or invalid")
@@ -149,7 +149,7 @@ func TestTodaySchemaLookup(t *testing.T) {
if !ok || items["type"] != "string" { if !ok || items["type"] != "string" {
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
} }
for _, field := range []string{"summary", "precipitation_timing", "confidence"} { for _, field := range []string{"summary", "precipitation_timing"} {
property, ok := schema.Properties[field].(map[string]any) property, ok := schema.Properties[field].(map[string]any)
if !ok { if !ok {
t.Fatalf("schema property %q missing or invalid", field) t.Fatalf("schema property %q missing or invalid", field)
@@ -214,7 +214,6 @@ func TestRenderHourly(t *testing.T) {
Summary: "Storm chances increase through late morning.", Summary: "Storm chances increase through late morning.",
ForecastDiscussion: "A front will keep the region unsettled.", ForecastDiscussion: "A front will keep the region unsettled.",
PrecipitationTiming: "A cold front is moving into the region.", PrecipitationTiming: "A cold front is moving into the region.",
Confidence: "Medium confidence in timing.",
}, },
Modules: testModules{ Modules: testModules{
CurrentConditions: &testCurrentConditions{ CurrentConditions: &testCurrentConditions{
@@ -282,7 +281,7 @@ func TestRenderHourly(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text) t.Fatalf("rendered template missing %q:\n%s", want, text)
} }
} }
if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") { if strings.Contains(text, "19%") || strings.Contains(text, "wind S") {
t.Fatalf("rendered template included omitted details:\n%s", text) t.Fatalf("rendered template included omitted details:\n%s", text)
} }
for _, unwanted := range []string{"Avoid low-water crossings.", "Slight risk for severe thunderstorms"} { for _, unwanted := range []string{"Avoid low-water crossings.", "Slight risk for severe thunderstorms"} {
@@ -955,7 +954,6 @@ type testGeneratedText struct {
Summary string Summary string
ForecastDiscussion string ForecastDiscussion string
PrecipitationTiming string PrecipitationTiming string
Confidence string
} }
type testTomorrowReportContext struct { type testTomorrowReportContext struct {
@@ -980,14 +978,12 @@ type testTomorrowGeneratedText struct {
Summary string Summary string
ForecastDiscussion []string ForecastDiscussion []string
PrecipitationTiming string PrecipitationTiming string
Confidence string
} }
type testDailyGeneratedText struct { type testDailyGeneratedText struct {
Summary string Summary string
ForecastDiscussion []string ForecastDiscussion []string
PrecipitationTiming string PrecipitationTiming string
Confidence string
} }
type testModules struct { type testModules struct {

View File

@@ -172,9 +172,9 @@ func validPreparationArtifact() PromptPreparationArtifact {
return PromptPreparationArtifact{ return PromptPreparationArtifact{
SchemaVersion: PromptPreparationSchemaVersion, Status: PromptPreparationSucceeded, SchemaVersion: PromptPreparationSchemaVersion, Status: PromptPreparationSucceeded,
ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text",
PromptVersion: "1.0.0", DataPackagePath: "/workspace/data.yaml", PromptVersion: "1.0.1", DataPackagePath: "/workspace/data.yaml",
Preparation: &promptexec.Preparation{ Preparation: &promptexec.Preparation{
PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.1",
DataPackagePath: "/workspace/data.yaml", DataPackagePath: "/workspace/data.yaml",
}, },
StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second, StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second,
@@ -186,9 +186,9 @@ func validExecutionArtifact() PromptExecutionArtifact {
validation := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "daily.generated_text.schema.json", nil) validation := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "daily.generated_text.schema.json", nil)
return PromptExecutionArtifact{ return PromptExecutionArtifact{
SchemaVersion: PromptExecutionSchemaVersion, Status: PromptExecutionSucceeded, SchemaVersion: PromptExecutionSchemaVersion, Status: PromptExecutionSucceeded,
ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.1",
Provenance: &PromptExecutionProvenance{ Provenance: &PromptExecutionProvenance{
RunID: "provider-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", RunID: "provider-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.1",
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: "profile", PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: "profile",
BackendID: "backend", ModelName: "model", DataPackagePath: "/workspace/data.yaml", BackendID: "backend", ModelName: "model", DataPackagePath: "/workspace/data.yaml",
StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second, StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second,
@@ -201,7 +201,7 @@ func validFailedExecutionArtifact() PromptExecutionArtifact {
started := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) started := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
return PromptExecutionArtifact{ return PromptExecutionArtifact{
SchemaVersion: PromptExecutionSchemaVersion, Status: PromptExecutionFailed, SchemaVersion: PromptExecutionSchemaVersion, Status: PromptExecutionFailed,
ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "1.0.1",
StartedAt: started, EndedAt: started, Error: &PromptArtifactError{Category: promptexec.Generation, Message: "provider unavailable"}, StartedAt: started, EndedAt: started, Error: &PromptArtifactError{Category: promptexec.Generation, Message: "provider unavailable"},
} }
} }