425 lines
15 KiB
Go
425 lines
15 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"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 ModulePromptExporter func(value any) (any, error)
|
|
|
|
type ModuleDefinition struct {
|
|
ID module.ID
|
|
StanzaName string
|
|
DefaultOptions any
|
|
RequiredCollected []module.FactRequirement
|
|
RequiredDerived []module.FactRequirement
|
|
SupportedReports []report.ID
|
|
MissingData module.MissingDataBehavior
|
|
AllowDuplicate bool
|
|
Builder ModuleBuilder
|
|
PromptExporter ModulePromptExporter
|
|
}
|
|
|
|
type ModuleRegistry struct {
|
|
definitions map[module.ID]ModuleDefinition
|
|
}
|
|
|
|
func unexpectedPromptExportValue(got any, want any) error {
|
|
return fmt.Errorf("value has type %T, want %T", got, want)
|
|
}
|
|
|
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
|
return NewModuleRegistry(defaultModuleDefinitions())
|
|
}
|
|
|
|
func MustDefaultModuleRegistry() ModuleRegistry {
|
|
registry, err := DefaultModuleRegistry()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return registry
|
|
}
|
|
|
|
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
|
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
|
seenStanzas := map[string]module.ID{}
|
|
for i, definition := range definitions {
|
|
if definition.ID == "" {
|
|
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
|
}
|
|
if definition.StanzaName == "" {
|
|
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
|
}
|
|
if _, ok := registry.definitions[definition.ID]; ok {
|
|
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
|
}
|
|
if definition.Builder == nil {
|
|
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
|
|
}
|
|
if definition.MissingData == module.MissingDataWarn {
|
|
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
|
}
|
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
|
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
|
}
|
|
seenStanzas[definition.StanzaName] = definition.ID
|
|
registry.definitions[definition.ID] = definition
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
|
definition, ok := r.definitions[id]
|
|
if !ok {
|
|
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
|
}
|
|
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)
|
|
}
|
|
missing := missingRequirements(definition, ctx)
|
|
if len(missing) > 0 {
|
|
switch definition.MissingData {
|
|
case module.MissingDataOmit:
|
|
return nil, nil
|
|
case module.MissingDataError:
|
|
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
|
|
case module.MissingDataEmpty:
|
|
case module.MissingDataWarn:
|
|
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
|
|
default:
|
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if definition.PromptExporter == nil {
|
|
output.PromptValue = output.Value
|
|
return output, nil
|
|
}
|
|
promptValue, err := definition.PromptExporter(output.Value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
|
}
|
|
output.PromptValue = promptValue
|
|
return output, nil
|
|
}
|
|
|
|
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
|
var missing []string
|
|
for _, requirement := range definition.RequiredCollected {
|
|
if !collectedFactAvailable(requirement, ctx) {
|
|
missing = append(missing, string(requirement))
|
|
}
|
|
}
|
|
for _, requirement := range definition.RequiredDerived {
|
|
if !derivedFactAvailable(requirement, ctx) {
|
|
missing = append(missing, string(requirement))
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
|
switch requirement {
|
|
case module.CollectedCurrentConditions:
|
|
return ctx.Collected.Current != nil
|
|
case module.CollectedNarrativeForecast:
|
|
return ctx.Collected.Narrative != nil
|
|
case module.CollectedHourlyForecast:
|
|
return ctx.Collected.Hourly != nil
|
|
case module.CollectedAlerts:
|
|
return ctx.Collected.Alerts != nil
|
|
case module.CollectedDiscussion:
|
|
return ctx.Collected.Discussion != nil
|
|
case module.CollectedWeatherStory:
|
|
return ctx.Collected.WeatherStory != nil
|
|
case module.CollectedSPCConvectiveOutlooks:
|
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
|
case module.CollectedSourceMetadata:
|
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
|
switch requirement {
|
|
case module.RequiresDerivedHourlyPeriods:
|
|
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
|
case module.RequiresDerivedNarrativePeriods:
|
|
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
|
case module.RequiresDerivedAlertOverlaps:
|
|
return true
|
|
case module.RequiresDerivedDailySummaries:
|
|
return len(ctx.Derived.DailySummaries) > 0
|
|
case module.RequiresDerivedDaypartSummaries:
|
|
return len(ctx.Derived.DaypartSummaries) > 0
|
|
case module.RequiresDerivedPrecipTiming:
|
|
return true
|
|
case module.RequiresDerivedSPCConvectiveOutlooks:
|
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
|
seenModules := map[module.ID]struct{}{}
|
|
seenStanzas := map[string]module.ID{}
|
|
for i, item := range items {
|
|
definition, err := r.Lookup(item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
|
}
|
|
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
|
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
|
}
|
|
seenModules[item.ID] = struct{}{}
|
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
|
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
|
}
|
|
seenStanzas[definition.StanzaName] = item.ID
|
|
if !definition.SupportsReport(reportID) {
|
|
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
|
}
|
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
|
if len(d.SupportedReports) == 0 {
|
|
return true
|
|
}
|
|
for _, supported := range d.SupportedReports {
|
|
if supported == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d ModuleDefinition) ValidateOptions(options any) error {
|
|
if options == nil {
|
|
return nil
|
|
}
|
|
if d.DefaultOptions == nil {
|
|
return fmt.Errorf("module %q does not accept options", d.ID)
|
|
}
|
|
want := reflect.TypeOf(d.DefaultOptions)
|
|
got := reflect.TypeOf(options)
|
|
if got == want {
|
|
return nil
|
|
}
|
|
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
|
}
|
|
|
|
func defaultModuleDefinitions() []ModuleDefinition {
|
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
|
return []ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
DefaultOptions: module.MetadataOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildMetadataModule,
|
|
},
|
|
{
|
|
ID: module.CurrentConditions,
|
|
StanzaName: "current_conditions",
|
|
DefaultOptions: module.CurrentConditionsOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildCurrentConditionsModule,
|
|
PromptExporter: exportCurrentConditionsPromptValue,
|
|
},
|
|
{
|
|
ID: module.NarrativeForecast,
|
|
StanzaName: "narrative_forecast",
|
|
DefaultOptions: module.NarrativeForecastOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildNarrativeForecastModule,
|
|
},
|
|
{
|
|
ID: module.HourlyForecast,
|
|
StanzaName: "hourly_forecast",
|
|
DefaultOptions: module.HourlyForecastOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildHourlyForecastModule,
|
|
PromptExporter: exportHourlyForecastPromptValue,
|
|
},
|
|
{
|
|
ID: module.DerivedDailySummary,
|
|
StanzaName: "derived_daily_summary",
|
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
|
MissingData: module.MissingDataError,
|
|
Builder: buildDerivedDailySummaryModule,
|
|
},
|
|
{
|
|
ID: module.DerivedDaypartSummaries,
|
|
StanzaName: "derived_daypart_summaries",
|
|
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
|
SupportedReports: daypartReports,
|
|
MissingData: module.MissingDataError,
|
|
Builder: buildDerivedDaypartSummariesModule,
|
|
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
|
},
|
|
{
|
|
ID: module.PrecipTiming,
|
|
StanzaName: "precip_timing",
|
|
DefaultOptions: module.PrecipTimingOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildPrecipTimingModule,
|
|
},
|
|
{
|
|
ID: module.AlertDigest,
|
|
StanzaName: "alert_digest",
|
|
DefaultOptions: module.AlertDigestOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildAlertDigestModule,
|
|
},
|
|
{
|
|
ID: module.SPCConvectiveOutlooks,
|
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildSPCConvectiveOutlooksModule,
|
|
},
|
|
{
|
|
ID: module.AreaForecastDiscussion,
|
|
StanzaName: "area_forecast_discussion",
|
|
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildAreaForecastDiscussionModule,
|
|
},
|
|
{
|
|
ID: module.SPCConvectiveDiscussion,
|
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildSPCConvectiveDiscussionModule,
|
|
},
|
|
{
|
|
ID: module.WeatherStory,
|
|
StanzaName: "weather_story",
|
|
DefaultOptions: module.WeatherStoryOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildWeatherStoryModule,
|
|
},
|
|
{
|
|
ID: module.OutdoorWindows,
|
|
StanzaName: "outdoor_windows",
|
|
DefaultOptions: module.OutdoorWindowsOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
|
SupportedReports: daypartReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildOutdoorWindowsModule,
|
|
},
|
|
{
|
|
ID: module.TodayPlanning,
|
|
StanzaName: "today_planning",
|
|
DefaultOptions: module.TodayPlanningOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.Today},
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildTodayPlanningModule,
|
|
},
|
|
{
|
|
ID: module.TomorrowPlanning,
|
|
StanzaName: "tomorrow_planning",
|
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.Tomorrow},
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildTomorrowPlanningModule,
|
|
},
|
|
{
|
|
ID: module.DailyPlanning,
|
|
StanzaName: "daily_planning",
|
|
DefaultOptions: module.DailyPlanningOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.Daily},
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildDailyPlanningModule,
|
|
},
|
|
}
|
|
}
|