diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index d19c613..e044307 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -77,13 +77,13 @@ are unavailable. Empty alert and outlook runs can still produce checked-empty modules. SPC discussion is omitted unless a retained categorical outlook meets the package's severity criterion and matching discussion text exists. -Effective units, timezone, and location context arrive in `ModuleContext` from -configuration and resolved report metadata. Report preparation also creates one -`PreparedIdentity` for the shared report identity, timing, configuration -context, and source warnings. Briefing metadata derives its matching fields -from that value; module projections retain their prompt-safe shape. Field -defaults are owned by [configuration](../config.md), and prompt-package layout -is owned by [prompt input](prompt-input.md). +`ModuleContext` carries the effective units, timezone, location context, and +prepared identity. Report preparation creates that one `PreparedIdentity` for +the shared report identity, timing, configuration context, and source warnings +before module construction. The metadata module projects its matching fields +from that value and retains its prompt-safe shape. Field defaults are owned by +[configuration](../config.md), and prompt-package layout is owned by [prompt +input](prompt-input.md). ## Verification and invariants diff --git a/docs/internal/prepared-report.md b/docs/internal/prepared-report.md index abf3ea9..b0c9a0b 100644 --- a/docs/internal/prepared-report.md +++ b/docs/internal/prepared-report.md @@ -6,15 +6,14 @@ and profile comparison; it is not a durable artifact. Preparation first establishes one `PreparedIdentity` for the report run, report and prompt IDs, variant, generation time, units, timezone, valid period, -location, and source warnings. It then builds report facts, the configured -module snapshot, briefing metadata, the curated prompt-input package, -serialized YAML, and the generated-text definition. The current metadata -projections remain equivalent to that identity while each retains its own -boundary-specific shape. +location, and source warnings. It passes that identity to the configured module +snapshot, curated prompt-input package, serialized YAML, generated-text render +context, and generated-text definition. Each boundary projects only the fields +it needs from that prepared authority. -Preparation deep-copies mutable facts, snapshots, identity, metadata, and -data-package bytes before returning them. Consumers receive independent copies -so one execution cannot change another's input or rendering context. +Preparation deep-copies mutable facts, snapshots, identity, and data-package +bytes before returning them. Consumers receive independent copies so one +execution cannot change another's input or rendering context. Single-report generation executes one prepared profile and publishes its Markdown. Comparison prepares once, gives every selected profile the same YAML diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 4e8d3bf..ed11bf5 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -4,7 +4,7 @@ ## Package Construction -`Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and prompt-safe source-warning summaries. Warning summaries include only source, code, severity, message, and completeness impact; raw transport and provenance fields such as endpoints never cross into the provider input. Prompt input contains no historical comparison section. +`Build` produces `weatherreporter.data_package.v4`. Its metadata projection comes from the prepared report identity and copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and prompt-safe source-warning summaries. Warning summaries include only source, code, severity, message, and completeness impact; raw transport and provenance fields such as endpoints never cross into the provider input. Prompt input contains no historical comparison section. Briefing is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated prompt exports take precedence and rich values are used only as a fallback. Prompt exports are selected by the [briefing registry](briefing.md), while the rich-versus-prompt contract is in [module internals](module.md). diff --git a/internal/app/app.go b/internal/app/app.go index 908e5b7..360b267 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -66,6 +66,7 @@ type BatchRequest struct { type ModuleSnapshotRequest struct { Config config.Config Resolved report.Resolved + Identity briefing.PreparedIdentity } type ReportFacts struct { @@ -729,7 +730,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF if err != nil { return module.Snapshot{}, err } + identity := req.Identity + if identity.ReportID == "" { + identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) + } moduleContext := briefing.ModuleContext{ + Identity: identity, Resolved: req.Resolved, Collected: reportFacts.Collected, Derived: reportFacts.Derived, @@ -761,16 +767,16 @@ func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected } } -func promptMetadata(metadata briefing.Metadata) promptinput.Metadata { +func promptMetadata(identity briefing.PreparedIdentity) promptinput.Metadata { return promptinput.Metadata{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - Variant: metadata.Variant, - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, - Timezone: metadata.Timezone, - ValidPeriod: metadata.ValidPeriod, - SourceWarnings: metadata.SourceWarnings, + RunID: identity.RunID, + ReportID: identity.ReportID, + Variant: identity.Variant, + PromptID: identity.PromptID, + GeneratedAt: identity.GeneratedAt, + Timezone: identity.Timezone, + ValidPeriod: identity.ValidPeriod, + SourceWarnings: identity.SourceWarnings, } } diff --git a/internal/app/prepared_report.go b/internal/app/prepared_report.go index 47e4580..9970f22 100644 --- a/internal/app/prepared_report.go +++ b/internal/app/prepared_report.go @@ -17,14 +17,13 @@ import ( // preparedReport contains the immutable deterministic inputs shared by prompt // executions for one resolved report. type preparedReport struct { - resolved report.Resolved - reportFacts ReportFacts - moduleSnapshot module.Snapshot - identity briefing.PreparedIdentity - briefingMetadata briefing.Metadata - sourceWarnings []weatherdata.SourceWarning - dataPackage []byte - handler generatedtext.Handler + resolved report.Resolved + reportFacts ReportFacts + moduleSnapshot module.Snapshot + identity briefing.PreparedIdentity + sourceWarnings []weatherdata.SourceWarning + dataPackage []byte + handler generatedtext.Handler } type prepareReportRequest struct { @@ -57,12 +56,11 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) { } buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected) identity := briefing.BuildPreparedIdentity(buildContext) - moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts) + moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved, Identity: identity}, reportFacts) if err != nil { return preparedReport{}, &preparationError{operation: "build module snapshot", err: err} } - metadata := briefing.BuildMetadata(identity, buildContext.Bundle) - dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot}) + dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot}) if err != nil { return preparedReport{}, &preparationError{operation: "build data package", err: err} } @@ -87,19 +85,14 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) { if err != nil { return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err} } - clonedMetadata, err := clonePreparedValue(metadata) - if err != nil { - return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err} - } prepared := preparedReport{ - resolved: cloneResolved(req.Resolved), - reportFacts: clonedFacts, - moduleSnapshot: clonedSnapshot, - identity: clonedIdentity, - briefingMetadata: clonedMetadata, - sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), - dataPackage: append([]byte(nil), serializedDataPackage...), - handler: handler, + resolved: cloneResolved(req.Resolved), + reportFacts: clonedFacts, + moduleSnapshot: clonedSnapshot, + identity: clonedIdentity, + sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), + dataPackage: append([]byte(nil), serializedDataPackage...), + handler: handler, } return prepared, nil } @@ -127,20 +120,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning { return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...) } -func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) { - metadata, err := clonePreparedValue(p.briefingMetadata) +func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, ReportFacts, error) { + identity, err := clonePreparedValue(p.identity) if err != nil { - return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err } snapshot, err := clonePreparedValue(p.moduleSnapshot) if err != nil { - return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err } reportFacts, err := clonePreparedValue(p.reportFacts) if err != nil { - return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err } - return metadata, snapshot, reportFacts, nil + return identity, snapshot, reportFacts, nil } func clonePreparedValue[T any](value T) (T, error) { diff --git a/internal/app/prepared_report_test.go b/internal/app/prepared_report_test.go index 3d9043f..8974f9d 100644 --- a/internal/app/prepared_report_test.go +++ b/internal/app/prepared_report_test.go @@ -31,19 +31,18 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { if err != nil { t.Fatalf("second prepareReport() error = %v", err) } - if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) { - t.Fatalf("prepared package/identity/metadata are not deterministic: %q/%#v/%#v", prepared.dataPackage, prepared.identity, prepared.briefingMetadata) + if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) { + t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity) } originalDataPackage := append([]byte(nil), prepared.dataPackage...) originalIdentity := prepared.identity - originalMetadata := prepared.briefingMetadata originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) - metadata, snapshot, reportFacts, err := prepared.renderInputs() + identity, snapshot, reportFacts, err := prepared.renderInputs() if err != nil { t.Fatalf("renderInputs() error = %v", err) } - metadata.SourceWarnings = append(metadata.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"}) + identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"}) snapshot.Outputs = nil reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation" bundle.Hourly.Periods[0].TextDescription = "mutated after preparation" @@ -55,7 +54,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { bundle.Sources[0].Query["mutated"] = "true" } - if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { + if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { t.Fatalf("prepared values changed after caller mutation: %#v", prepared) } if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { @@ -66,7 +65,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { } } -func TestPrepareReportSharesOneMetadataIdentity(t *testing.T) { +func TestPrepareReportProjectsPreparedIdentity(t *testing.T) { cfg := generationConfig() bundle := generationBundle(t) resolved, err := ResolveGenerate(GenerateRequest{ @@ -82,12 +81,14 @@ func TestPrepareReportSharesOneMetadataIdentity(t *testing.T) { } identity := prepared.identity - metadata := prepared.briefingMetadata - if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) { - t.Fatalf("briefing metadata does not match prepared identity: %#v/%#v", metadata, identity) + renderIdentity, _, _, err := prepared.renderInputs() + if err != nil { + t.Fatalf("renderInputs() error = %v", err) } - - prompt := promptMetadata(metadata) + if !reflect.DeepEqual(renderIdentity, identity) { + t.Fatalf("render identity = %#v, want %#v", renderIdentity, identity) + } + prompt := promptMetadata(identity) if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) { t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity) } diff --git a/internal/app/profile_execution.go b/internal/app/profile_execution.go index b46b996..ccda9ea 100644 --- a/internal/app/profile_execution.go +++ b/internal/app/profile_execution.go @@ -115,11 +115,11 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p if err != nil { return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err} } - metadata, snapshot, reportFacts, err := req.Prepared.renderInputs() + identity, snapshot, reportFacts, err := req.Prepared.renderInputs() if err != nil { return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err} } - renderContext, err := req.Prepared.handler.BuildRenderContext(metadata, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText) + renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText) if err != nil { return outcome, nil, &profileExecutionError{operation: "build render context", err: err} } diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 513cd66..a1e2fc2 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -600,7 +600,7 @@ func testModuleContext() ModuleContext { hourlyHumidity := 66.0 hourlyWindMph := 14.0 updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") - return ModuleContext{ + ctx := ModuleContext{ Resolved: resolved, Collected: facts.CollectedFacts{ Current: &weatherdata.Current{ @@ -728,6 +728,14 @@ func testModuleContext() ModuleContext { Timezone: "America/Chicago", }, } + ctx.Identity = BuildPreparedIdentity(BuildContext{ + Resolved: ctx.Resolved, + Bundle: ctx.Collected.Bundle(), + Units: ctx.Units, + Timezone: ctx.Timezone, + Location: ctx.Location, + }) + return ctx } func moduleValue[T any](t *testing.T, output *module.Output) T { diff --git a/internal/briefing/metadata_module.go b/internal/briefing/metadata_module.go index 3184371..a05309d 100644 --- a/internal/briefing/metadata_module.go +++ b/internal/briefing/metadata_module.go @@ -31,18 +31,17 @@ type SourceWarningSummary struct { } 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), + RunID: ctx.Identity.RunID, + ReportID: ctx.Identity.ReportID, + Variant: ctx.Identity.Variant, + PromptID: ctx.Identity.PromptID, + GeneratedAt: ctx.Identity.GeneratedAt, + Units: ctx.Identity.Units, + Timezone: ctx.Identity.Timezone, + ValidPeriod: ctx.Identity.ValidPeriod, + Location: copyLocation(ctx.Identity.Location), + SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings), } return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil } diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 29ae037..bc1576b 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -11,6 +11,7 @@ import ( ) type ModuleContext struct { + Identity PreparedIdentity Resolved report.Resolved Collected facts.CollectedFacts Derived facts.DerivedFacts diff --git a/internal/briefing/package.go b/internal/briefing/package.go index 7f8b28d..8a783a9 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -9,7 +9,9 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) -type Metadata struct { +// PreparedIdentity is the single prepared authority for report identity, +// timing, configuration context, and source warnings. +type PreparedIdentity struct { RunID string `json:"runId"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` @@ -21,24 +23,7 @@ type Metadata struct { Location *LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` - Sources []SourceMetadata `json:"sources,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` - Alerts *AlertStatus `json:"alerts,omitempty"` -} - -// PreparedIdentity is the single prepared authority for report identity, -// timing, configuration context, and source warnings. -type PreparedIdentity struct { - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Units string `json:"units"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` - Location *LocationContext `json:"location,omitempty"` - SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` } type LocationContext struct { @@ -48,24 +33,6 @@ type LocationContext struct { Timezone string `json:"timezone,omitempty"` } -type SourceMetadata struct { - Name string `json:"name"` - Endpoint string `json:"endpoint,omitempty"` - FetchedAt time.Time `json:"fetchedAt"` - IssuedAt *time.Time `json:"issuedAt,omitempty"` - UpdatedAt *time.Time `json:"updatedAt,omitempty"` - DataSHA256 string `json:"dataSha256,omitempty"` - Missing bool `json:"missing,omitempty"` - Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"` -} - -type AlertStatus struct { - Checked bool `json:"checked"` - ActiveCount int `json:"activeCount"` - RelevantCount int `json:"relevantCount"` - Missing bool `json:"missing,omitempty"` -} - type BuildContext struct { Resolved report.Resolved Bundle *weatherdata.Bundle @@ -76,37 +43,20 @@ type BuildContext struct { func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity { metadata := ctx.Resolved.Metadata() + sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) return PreparedIdentity{ - 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: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...), - } -} - -func BuildMetadata(identity PreparedIdentity, bundle *weatherdata.Bundle) Metadata { - sourceLocationID, sourceLocation := sourceLocation(bundle) - return Metadata{ - RunID: identity.RunID, - ReportID: identity.ReportID, - Variant: identity.Variant, - PromptID: identity.PromptID, - GeneratedAt: identity.GeneratedAt, - Units: identity.Units, - Timezone: identity.Timezone, - ValidPeriod: identity.ValidPeriod, - Location: copyLocation(identity.Location), + 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), SourceLocationID: sourceLocationID, SourceLocation: sourceLocation, - Sources: sourceMetadata(bundle), - SourceWarnings: append([]weatherdata.SourceWarning(nil), identity.SourceWarnings...), - Alerts: alertStatus(bundle), + SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...), } } @@ -165,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) { return "", "" } -func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata { - if bundle == nil { - return nil - } - out := make([]SourceMetadata, 0, len(bundle.Sources)) - for _, source := range bundle.Sources { - out = append(out, SourceMetadata{ - Name: source.Name, - Endpoint: source.Endpoint, - FetchedAt: source.FetchedAt, - IssuedAt: source.IssuedAt, - UpdatedAt: source.UpdatedAt, - DataSHA256: source.DataSHA256, - Missing: source.Missing, - Warnings: source.Warnings, - }) - } - return out -} - func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning { if bundle == nil { return nil @@ -192,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning { return bundle.Warnings } -func alertStatus(bundle *weatherdata.Bundle) *AlertStatus { - if bundle == nil { - return nil - } - status := &AlertStatus{} - if bundle.Alerts != nil { - status.Checked = true - status.ActiveCount = len(bundle.Alerts.Alerts) - } - for _, source := range bundle.Sources { - if source.Name == "alerts" && source.Missing { - status.Missing = true - break - } - } - if !status.Checked && !status.Missing { - return nil - } - return status -} - func variantForReport(id report.ID) string { switch id { case report.Daily, report.Today: diff --git a/internal/briefing/prepared_identity_test.go b/internal/briefing/prepared_identity_test.go index fb44c2a..78bced0 100644 --- a/internal/briefing/prepared_identity_test.go +++ b/internal/briefing/prepared_identity_test.go @@ -3,9 +3,11 @@ package briefing import ( "reflect" "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) -func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) { +func TestPreparedIdentityCopiesMutableFields(t *testing.T) { moduleContext := testModuleContext() context := BuildContext{ Resolved: moduleContext.Resolved, @@ -15,18 +17,10 @@ func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) { Location: moduleContext.Location, } identity := BuildPreparedIdentity(context) - metadata := BuildMetadata(identity, context.Bundle) - - if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod { - t.Fatalf("metadata identity = %#v, want %#v", metadata, identity) - } - if !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) { - t.Fatalf("metadata location/warnings = %#v/%#v, want %#v/%#v", metadata.Location, metadata.SourceWarnings, identity.Location, identity.SourceWarnings) - } - - metadata.Location.Name = "consumer mutation" - metadata.SourceWarnings = append(metadata.SourceWarnings, moduleContext.Collected.SourceWarnings[0]) - if identity.Location.Name == "consumer mutation" || len(identity.SourceWarnings) != len(moduleContext.Collected.SourceWarnings) { - t.Fatalf("identity changed through metadata mutation: %#v", identity) + originalWarnings := append([]weatherdata.SourceWarning(nil), moduleContext.Collected.SourceWarnings...) + identity.Location.Name = "consumer mutation" + identity.SourceWarnings = append(identity.SourceWarnings, moduleContext.Collected.SourceWarnings[0]) + if moduleContext.Location.Name == "consumer mutation" || !reflect.DeepEqual(moduleContext.Collected.SourceWarnings, originalWarnings) { + t.Fatalf("prepared identity changed its source context: %#v", identity) } } diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index 38575c8..9c4aaf0 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -25,7 +25,7 @@ const ( type validator func([]byte) (any, []byte, error) -type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error) +type renderContextBuilder func(report.ID, string, briefing.PreparedIdentity, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error) type catalogEntry struct { reportID report.ID @@ -140,11 +140,11 @@ func (h Handler) Validate(data []byte) (any, []byte, error) { return h.validate(data) } -func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { +func (h Handler) BuildRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { if h.renderContextBuilder == nil { return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID) } - return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated) + return h.renderContextBuilder(h.reportID, h.templateID, identity, snapshot, collected, derived, generated) } func (h Handler) Render(data any) ([]byte, error) { @@ -171,34 +171,34 @@ func validateTomorrow(data []byte) (any, []byte, error) { return ValidateTomorrow(data) } -func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { +func buildHourlyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { hourly, ok := generated.(Hourly) if !ok { return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID) } - return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived) + return BuildHourlyRenderContext(identity, snapshot, hourly, collected, derived) } -func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { +func buildDailyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { daily, ok := generated.(Daily) if !ok { return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID) } - return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived) + return BuildDailyRenderContext(identity, snapshot, daily, collected, derived) } -func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { +func buildTodayContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { today, ok := generated.(Today) if !ok { return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID) } - return BuildTodayRenderContext(metadata, snapshot, today, collected, derived) + return BuildTodayRenderContext(identity, snapshot, today, collected, derived) } -func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { +func buildTomorrowContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { tomorrow, ok := generated.(Tomorrow) if !ok { return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID) } - return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived) + return BuildTomorrowRenderContext(identity, snapshot, tomorrow, collected, derived) } diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index b9baa49..ecd3504 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -188,15 +188,15 @@ type dayStyleTemplateModules struct { WeatherStory *briefing.WeatherStoryModule } -func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { - location, err := timeutil.LoadLocation(metadata.Timezone) +func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { + location, err := timeutil.LoadLocation(identity.Timezone) if err != nil { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) } - if metadata.GeneratedAt.IsZero() { + if identity.GeneratedAt.IsZero() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required") } - if !metadata.ValidPeriod.IsValid() { + if !identity.ValidPeriod.IsValid() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required") } modules, err := hourlyTemplateModules(snapshot) @@ -206,12 +206,12 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh return HourlyRenderContext{ Report: HourlyReportContext{ Title: "Hourly Report", - LocationName: locationName(metadata), - GeneratedAt: metadata.GeneratedAt, - GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), - ValidPeriod: metadata.ValidPeriod, - ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), - Timezone: metadata.Timezone, + LocationName: locationName(identity), + GeneratedAt: identity.GeneratedAt, + GeneratedAtLabel: generatedAtLabel(identity.GeneratedAt, location), + ValidPeriod: identity.ValidPeriod, + ValidPeriodLabel: periodLabel(identity.ValidPeriod, location), + Timezone: identity.Timezone, }, GeneratedText: generated, Modules: modules, @@ -220,8 +220,8 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh }, nil } -func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { - reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string { +func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { + reportContext, err := buildDayStyleReportContext(identity, "daily", func(dayName string) string { return dayName + "'s Weather" }) if err != nil { @@ -240,8 +240,8 @@ func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapsho }, nil } -func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { - reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string { +func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { + reportContext, err := buildDayStyleReportContext(identity, "today", func(string) string { return "Today's Weather" }) if err != nil { @@ -260,8 +260,8 @@ func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapsho }, nil } -func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { - reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string { +func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { + reportContext, err := buildDayStyleReportContext(identity, "tomorrow", func(dayName string) string { return dayName + "'s Weather" }) if err != nil { @@ -280,29 +280,29 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap }, nil } -func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) { - location, err := timeutil.LoadLocation(metadata.Timezone) +func buildDayStyleReportContext(identity briefing.PreparedIdentity, name string, title func(string) string) (dayStyleReportContext, error) { + location, err := timeutil.LoadLocation(identity.Timezone) if err != nil { return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err) } - if metadata.GeneratedAt.IsZero() { + if identity.GeneratedAt.IsZero() { return dayStyleReportContext{}, fmt.Errorf("build %s render context: generatedAt is required", name) } - if !metadata.ValidPeriod.IsValid() { + if !identity.ValidPeriod.IsValid() { return dayStyleReportContext{}, fmt.Errorf("build %s render context: valid period is required", name) } - forecastDate := metadata.ValidPeriod.Start.In(location) + forecastDate := identity.ValidPeriod.Start.In(location) forecastDayName := forecastDate.Format("Monday") return dayStyleReportContext{ Title: title(forecastDayName), ForecastDate: forecastDate, ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), ForecastDayName: forecastDayName, - GeneratedAt: metadata.GeneratedAt, - GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), - ValidPeriod: metadata.ValidPeriod, - Timezone: metadata.Timezone, + GeneratedAt: identity.GeneratedAt, + GeneratedAtLabel: generatedAtLabel(identity.GeneratedAt, location), + ValidPeriod: identity.ValidPeriod, + Timezone: identity.Timezone, }, nil } @@ -706,20 +706,20 @@ func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string { return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key} } -func locationName(metadata briefing.Metadata) string { - if metadata.Location != nil { - if metadata.Location.Name != "" && metadata.Location.Region != "" { - return metadata.Location.Name + ", " + metadata.Location.Region +func locationName(identity briefing.PreparedIdentity) string { + if identity.Location != nil { + if identity.Location.Name != "" && identity.Location.Region != "" { + return identity.Location.Name + ", " + identity.Location.Region } - if metadata.Location.Name != "" { - return metadata.Location.Name + if identity.Location.Name != "" { + return identity.Location.Name } } - if metadata.SourceLocation != "" { - return metadata.SourceLocation + if identity.SourceLocation != "" { + return identity.SourceLocation } - if metadata.SourceLocationID != "" { - return metadata.SourceLocationID + if identity.SourceLocationID != "" { + return identity.SourceLocationID } return "Unknown location" } diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 31ba403..66d77c2 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -741,9 +741,9 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { } } -func testMetadata() briefing.Metadata { +func testMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) - return briefing.Metadata{ + return briefing.PreparedIdentity{ RunID: "run-1", ReportID: report.Hourly, PromptID: "weather.hourly_generated_text", @@ -761,9 +761,9 @@ func testMetadata() briefing.Metadata { } } -func testTomorrowMetadata() briefing.Metadata { +func testTomorrowMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC) - return briefing.Metadata{ + return briefing.PreparedIdentity{ RunID: "run-tomorrow", ReportID: report.Tomorrow, PromptID: "weather.tomorrow_generated_text", @@ -781,9 +781,9 @@ func testTomorrowMetadata() briefing.Metadata { } } -func testDailyMetadata() briefing.Metadata { +func testDailyMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC) - return briefing.Metadata{ + return briefing.PreparedIdentity{ RunID: "run-daily", ReportID: report.Daily, PromptID: "weather.daily_generated_text", @@ -801,9 +801,9 @@ func testDailyMetadata() briefing.Metadata { } } -func testTodayMetadata() briefing.Metadata { +func testTodayMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC) - return briefing.Metadata{ + return briefing.PreparedIdentity{ RunID: "run-today", ReportID: report.Today, PromptID: "weather.today_generated_text",