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 { Identity PreparedIdentity 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 []*factRequirement RequiredDerived []*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 err := validateFactRequirements(definition); err != nil { return ModuleRegistry{}, err } 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) } options, err := definition.CanonicalOptions(item.Options) if 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) } } 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 !requirement.available(ctx) { missing = append(missing, requirement.String()) } } for _, requirement := range definition.RequiredDerived { if !requirement.available(ctx) { missing = append(missing, requirement.String()) } } return missing } func validateFactRequirements(definition ModuleDefinition) error { if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil { return err } return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement) } func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error { for _, requirement := range requirements { if requirement == nil { return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "") } descriptor, ok := lookupFactRequirement(requirement.identity) if !ok || descriptor != requirement { return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity) } if descriptor.category != want { return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want) } } return nil } func lookupFactRequirement(identity string) (*factRequirement, bool) { for _, requirement := range factRequirementVocabulary { if requirement.identity == identity { return requirement, true } } return nil, 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) } // CanonicalOptions validates options and converts an accepted typed pointer to its value form. func (d ModuleDefinition) CanonicalOptions(options any) (any, error) { if err := d.ValidateOptions(options); err != nil { return nil, err } if options == nil { return nil, nil } value := reflect.ValueOf(options) if value.Kind() != reflect.Pointer { return options, nil } if value.IsNil() { return nil, fmt.Errorf("module %q options must not be a nil pointer", d.ID) } return value.Elem().Interface(), nil } func defaultModuleDefinitions() []ModuleDefinition { allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly} daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow} return []ModuleDefinition{ { ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, RequiredCollected: []*factRequirement{sourceMetadataRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildMetadataModule, }, { ID: module.CurrentConditions, StanzaName: "current_conditions", DefaultOptions: module.CurrentConditionsOptions{}, RequiredCollected: []*factRequirement{currentConditionsRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildCurrentConditionsModule, PromptExporter: exportCurrentConditionsPromptValue, }, { ID: module.NarrativeForecast, StanzaName: "narrative_forecast", DefaultOptions: module.NarrativeForecastOptions{}, RequiredCollected: []*factRequirement{narrativeForecastRequirement}, RequiredDerived: []*factRequirement{narrativePeriodsRequirement}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, MissingData: module.MissingDataOmit, Builder: buildNarrativeForecastModule, }, { ID: module.HourlyForecast, StanzaName: "hourly_forecast", DefaultOptions: module.HourlyForecastOptions{}, RequiredCollected: []*factRequirement{hourlyForecastRequirement}, RequiredDerived: []*factRequirement{hourlyPeriodsRequirement}, 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: []*factRequirement{dailySummariesRequirement, precipTimingRequirement}, 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: []*factRequirement{daypartSummariesRequirement}, SupportedReports: daypartReports, MissingData: module.MissingDataError, Builder: buildDerivedDaypartSummariesModule, PromptExporter: exportDerivedDaypartSummariesPromptValue, }, { ID: module.PrecipTiming, StanzaName: "precip_timing", DefaultOptions: module.PrecipTimingOptions{}, RequiredDerived: []*factRequirement{precipTimingRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildPrecipTimingModule, }, { ID: module.AlertDigest, StanzaName: "alert_digest", DefaultOptions: module.AlertDigestOptions{}, RequiredCollected: []*factRequirement{alertsRequirement}, RequiredDerived: []*factRequirement{alertOverlapsRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildAlertDigestModule, }, { ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), DefaultOptions: module.SPCConvectiveOutlooksOptions{}, RequiredCollected: []*factRequirement{spcOutlooksRequirement}, RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildSPCConvectiveOutlooksModule, }, { ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", DefaultOptions: module.AreaForecastDiscussionOptions{}, RequiredCollected: []*factRequirement{discussionRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildAreaForecastDiscussionModule, }, { ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), DefaultOptions: module.SPCConvectiveDiscussionOptions{}, RequiredCollected: []*factRequirement{spcOutlooksRequirement}, RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildSPCConvectiveDiscussionModule, }, { ID: module.WeatherStory, StanzaName: "weather_story", DefaultOptions: module.WeatherStoryOptions{}, RequiredCollected: []*factRequirement{weatherStoryRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildWeatherStoryModule, }, { ID: module.OutdoorWindows, StanzaName: "outdoor_windows", DefaultOptions: module.OutdoorWindowsOptions{}, RequiredDerived: []*factRequirement{daypartSummariesRequirement}, SupportedReports: daypartReports, MissingData: module.MissingDataEmpty, Builder: buildOutdoorWindowsModule, }, { ID: module.TodayPlanning, StanzaName: "today_planning", DefaultOptions: module.TodayPlanningOptions{}, RequiredDerived: []*factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Today}, MissingData: module.MissingDataEmpty, Builder: buildTodayPlanningModule, }, { ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", DefaultOptions: module.TomorrowPlanningOptions{}, RequiredDerived: []*factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Tomorrow}, MissingData: module.MissingDataEmpty, Builder: buildTomorrowPlanningModule, }, { ID: module.DailyPlanning, StanzaName: "daily_planning", DefaultOptions: module.DailyPlanningOptions{}, RequiredDerived: []*factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Daily}, MissingData: module.MissingDataEmpty, Builder: buildDailyPlanningModule, }, } }