Implement source-oriented briefing modules
This commit is contained in:
@@ -23,6 +23,8 @@ Outputs:
|
|||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
object for Daily, 3-Day, Weekend, or Storm Report
|
||||||
- module registry definitions for known module IDs, stanza names, option
|
- module registry definitions for known module IDs, stanza names, option
|
||||||
shapes, fact requirements, report compatibility, and missing-data behavior
|
shapes, fact requirements, report compatibility, and missing-data behavior
|
||||||
|
- source-oriented module outputs for `metadata`, `current_conditions`,
|
||||||
|
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||||
- optional `currentConditions` prompt context from normalized
|
- optional `currentConditions` prompt context from normalized
|
||||||
`/conditions/current` data when available
|
`/conditions/current` data when available
|
||||||
- optional structured `weatherStory` context on report-specific briefing
|
- optional structured `weatherStory` context on report-specific briefing
|
||||||
@@ -73,6 +75,10 @@ None. Builders either return a complete briefing package or an error.
|
|||||||
stanza names.
|
stanza names.
|
||||||
- Module composition validation rejects unknown modules, duplicate modules,
|
- Module composition validation rejects unknown modules, duplicate modules,
|
||||||
incompatible report/module combinations, and invalid typed options.
|
incompatible report/module combinations, and invalid typed options.
|
||||||
|
- Source-oriented module builders omit missing optional current conditions,
|
||||||
|
forecast discussion, and weather story stanzas.
|
||||||
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
|
source data.
|
||||||
- Save failures include path and operation context.
|
- Save failures include path and operation context.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -83,6 +89,7 @@ Inspect:
|
|||||||
- `internal/briefing/three_day_test.go`
|
- `internal/briefing/three_day_test.go`
|
||||||
- `internal/briefing/weekend_test.go`
|
- `internal/briefing/weekend_test.go`
|
||||||
- `internal/briefing/storm_test.go`
|
- `internal/briefing/storm_test.go`
|
||||||
|
- `internal/briefing/base_modules_test.go`
|
||||||
- `internal/briefing/modules_test.go`
|
- `internal/briefing/modules_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
|
|||||||
236
internal/briefing/base_modules.go
Normal file
236
internal/briefing/base_modules.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetadataModule struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
ReportID report.ID `json:"report_id"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Units string `json:"units"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
|
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceWarningSummary struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentConditionsModule struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertDigestModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
RelevantCount int `json:"relevant_count"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertSummary struct {
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AreaForecastDiscussionModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
KeyMessages []string `json:"key_messages,omitempty"`
|
||||||
|
ShortTerm string `json:"short_term,omitempty"`
|
||||||
|
LongTerm string `json:"long_term,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeatherStoryModule struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"alt_text,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
metadata := ctx.Resolved.Metadata()
|
||||||
|
value := MetadataModule{
|
||||||
|
RunID: metadata.RunID,
|
||||||
|
ReportID: metadata.ReportID,
|
||||||
|
Variant: variantForReport(metadata.ReportID),
|
||||||
|
PromptID: metadata.PromptID,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
Units: ctx.Units,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
|
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
current := ctx.Collected.Current
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := CurrentConditionsModule{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: copyFloat(current.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||||
|
DewpointC: copyFloat(current.DewpointC),
|
||||||
|
DewpointF: copyFloat(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
|
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
||||||
|
if value == nil {
|
||||||
|
value = &AlertDigestModule{}
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAreaForecastDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
discussion := ctx.Collected.Discussion
|
||||||
|
if discussion == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := AreaForecastDiscussionModule{
|
||||||
|
Product: discussion.Product,
|
||||||
|
KeyMessages: append([]string(nil), discussion.KeyMessages...),
|
||||||
|
}
|
||||||
|
if discussion.ShortTerm != nil {
|
||||||
|
value.ShortTerm = discussion.ShortTerm.Text
|
||||||
|
}
|
||||||
|
if discussion.LongTerm != nil {
|
||||||
|
value.LongTerm = discussion.LongTerm.Text
|
||||||
|
}
|
||||||
|
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
story := ctx.Collected.WeatherStory
|
||||||
|
if story == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||||
|
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||||
|
for _, warning := range warnings {
|
||||||
|
out = append(out, SourceWarningSummary{
|
||||||
|
Source: warning.Source,
|
||||||
|
Code: warning.Code,
|
||||||
|
Severity: warning.Severity,
|
||||||
|
Message: warning.Message,
|
||||||
|
CompletenessImpact: warning.CompletenessImpact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
||||||
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
|
if collected.Alerts == nil && !missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := &AlertDigestModule{Missing: missing}
|
||||||
|
if collected.Alerts != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
value.RelevantCount = len(overlaps)
|
||||||
|
for _, overlap := range overlaps {
|
||||||
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
|
Event: overlap.Event,
|
||||||
|
Headline: overlap.Headline,
|
||||||
|
Severity: overlap.Severity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name && source.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
|
return v.ConditionText == "" &&
|
||||||
|
v.IsDay == nil &&
|
||||||
|
v.TemperatureC == nil &&
|
||||||
|
v.TemperatureF == nil &&
|
||||||
|
v.ApparentTemperatureC == nil &&
|
||||||
|
v.ApparentTemperatureF == nil &&
|
||||||
|
v.DewpointC == nil &&
|
||||||
|
v.DewpointF == nil &&
|
||||||
|
v.RelativeHumidityPercent == nil &&
|
||||||
|
v.WindSpeedKmh == nil &&
|
||||||
|
v.WindSpeedMph == nil &&
|
||||||
|
v.WindDirectionDegrees == nil
|
||||||
|
}
|
||||||
276
internal/briefing/base_modules_test.go
Normal file
276
internal/briefing/base_modules_test.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
id module.ID
|
||||||
|
stanza string
|
||||||
|
}{
|
||||||
|
{id: module.Metadata, stanza: "metadata"},
|
||||||
|
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||||
|
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||||
|
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||||
|
{id: module.WeatherStory, stanza: "weather_story"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want stanza")
|
||||||
|
}
|
||||||
|
if output.ID != tt.id || output.StanzaName != tt.stanza {
|
||||||
|
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[MetadataModule](t, output)
|
||||||
|
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
||||||
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
|
}
|
||||||
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Location = %#v, want configured location", value.Location)
|
||||||
|
}
|
||||||
|
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||||
|
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||||
|
}
|
||||||
|
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
|
||||||
|
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
|
||||||
|
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[CurrentConditionsModule](t, output)
|
||||||
|
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
|
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.Alerts = &weatherdata.AlertRun{}
|
||||||
|
ctx.Derived.AlertOverlaps = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(checked empty) error = %v", err)
|
||||||
|
}
|
||||||
|
checked := moduleValue[AlertDigestModule](t, output)
|
||||||
|
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
|
||||||
|
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Collected.Alerts = nil
|
||||||
|
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
|
||||||
|
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(missing) error = %v", err)
|
||||||
|
}
|
||||||
|
missing := moduleValue[AlertDigestModule](t, output)
|
||||||
|
if missing.Checked || !missing.Missing {
|
||||||
|
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.Current = nil
|
||||||
|
ctx.Collected.Discussion = nil
|
||||||
|
ctx.Collected.WeatherStory = nil
|
||||||
|
|
||||||
|
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(afd) error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
|
||||||
|
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
|
||||||
|
t.Fatalf("AFD = %#v, want discussion sections", afd)
|
||||||
|
}
|
||||||
|
|
||||||
|
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(weather story) error = %v", err)
|
||||||
|
}
|
||||||
|
story := moduleValue[WeatherStoryModule](t, storyOutput)
|
||||||
|
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(storyOutput.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal weather story: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "download_url") {
|
||||||
|
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModuleContext() ModuleContext {
|
||||||
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||||
|
resolved := report.Resolved{
|
||||||
|
Definition: definition,
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||||
|
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
isDay := true
|
||||||
|
tempF := 74.0
|
||||||
|
apparentF := 76.0
|
||||||
|
humidity := 71.0
|
||||||
|
windMph := 8.0
|
||||||
|
windDirection := 190.0
|
||||||
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: resolved,
|
||||||
|
Collected: facts.CollectedFacts{
|
||||||
|
Current: &weatherdata.Current{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
IsDay: &isDay,
|
||||||
|
TemperatureF: &tempF,
|
||||||
|
ApparentTemperatureF: &apparentF,
|
||||||
|
RelativeHumidityPercent: &humidity,
|
||||||
|
WindSpeedMph: &windMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
},
|
||||||
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||||
|
}},
|
||||||
|
Discussion: &weatherdata.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Scattered showers are possible."},
|
||||||
|
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
|
||||||
|
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
|
||||||
|
},
|
||||||
|
WeatherStory: &weatherdata.WeatherStory{
|
||||||
|
OfficeID: "LSX",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Title: "Rain Chances",
|
||||||
|
Description: "Scattered showers are possible.",
|
||||||
|
AltText: "Weather story graphic with rain chances.",
|
||||||
|
Priority: true,
|
||||||
|
Order: 1,
|
||||||
|
DownloadURL: "https://example.invalid/story.png",
|
||||||
|
},
|
||||||
|
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
|
||||||
|
SourceWarnings: []weatherdata.SourceWarning{{
|
||||||
|
Source: "daily",
|
||||||
|
Code: "missing_source",
|
||||||
|
Severity: "warning",
|
||||||
|
Message: "daily source is missing",
|
||||||
|
Endpoint: "/forecast/daily",
|
||||||
|
CompletenessImpact: "source omitted",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Derived: facts.DerivedFacts{
|
||||||
|
AlertOverlaps: []forecast.AlertOverlap{{
|
||||||
|
Event: "Flood Watch",
|
||||||
|
Headline: "Flooding possible",
|
||||||
|
Severity: "Moderate",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Units: "us",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
Location: &LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
t.Helper()
|
||||||
|
var value T
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module value: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatalf("decode module value: %v", err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseModuleTime(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -4,10 +4,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ModuleContext struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
Units string
|
||||||
|
Timezone string
|
||||||
|
Location *LocationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
type ModuleDefinition struct {
|
type ModuleDefinition struct {
|
||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
@@ -17,6 +29,7 @@ type ModuleDefinition struct {
|
|||||||
SupportedReports []report.ID
|
SupportedReports []report.ID
|
||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
|
Builder ModuleBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleRegistry struct {
|
type ModuleRegistry struct {
|
||||||
@@ -65,6 +78,40 @@ func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
|||||||
return definition, nil
|
return definition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
||||||
|
definition, err := r.Lookup(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||||
|
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if definition.Builder == nil {
|
||||||
|
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||||
|
}
|
||||||
|
options := item.Options
|
||||||
|
if options == nil {
|
||||||
|
options = definition.DefaultOptions
|
||||||
|
}
|
||||||
|
output, err := definition.Builder(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if output.ID != definition.ID {
|
||||||
|
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
||||||
|
}
|
||||||
|
if output.StanzaName != definition.StanzaName {
|
||||||
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||||
seenModules := map[module.ID]struct{}{}
|
seenModules := map[module.ID]struct{}{}
|
||||||
seenStanzas := map[string]module.ID{}
|
seenStanzas := map[string]module.ID{}
|
||||||
@@ -132,6 +179,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildMetadataModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.CurrentConditions,
|
ID: module.CurrentConditions,
|
||||||
@@ -140,6 +188,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildCurrentConditionsModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
@@ -181,6 +230,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildAlertDigestModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
@@ -189,6 +239,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
@@ -197,6 +248,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildWeatherStoryModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.ForecastDelta,
|
ID: module.ForecastDelta,
|
||||||
|
|||||||
Reference in New Issue
Block a user