Route report projections through prepared identity

This commit is contained in:
2026-08-13 02:00:18 +00:00
parent e2dd8d0e29
commit 360c665a3e
15 changed files with 163 additions and 253 deletions

View File

@@ -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 modules. SPC discussion is omitted unless a retained categorical outlook meets
the package's severity criterion and matching discussion text exists. the package's severity criterion and matching discussion text exists.
Effective units, timezone, and location context arrive in `ModuleContext` from `ModuleContext` carries the effective units, timezone, location context, and
configuration and resolved report metadata. Report preparation also creates one prepared identity. Report preparation creates that one `PreparedIdentity` for
`PreparedIdentity` for the shared report identity, timing, configuration the shared report identity, timing, configuration context, and source warnings
context, and source warnings. Briefing metadata derives its matching fields before module construction. The metadata module projects its matching fields
from that value; module projections retain their prompt-safe shape. Field from that value and retains its prompt-safe shape. Field defaults are owned by
defaults are owned by [configuration](../config.md), and prompt-package layout [configuration](../config.md), and prompt-package layout is owned by [prompt
is owned by [prompt input](prompt-input.md). input](prompt-input.md).
## Verification and invariants ## Verification and invariants

View File

@@ -6,15 +6,14 @@ and profile comparison; it is not a durable artifact.
Preparation first establishes one `PreparedIdentity` for the report run, report Preparation first establishes one `PreparedIdentity` for the report run, report
and prompt IDs, variant, generation time, units, timezone, valid period, and prompt IDs, variant, generation time, units, timezone, valid period,
location, and source warnings. It then builds report facts, the configured location, and source warnings. It passes that identity to the configured module
module snapshot, briefing metadata, the curated prompt-input package, snapshot, curated prompt-input package, serialized YAML, generated-text render
serialized YAML, and the generated-text definition. The current metadata context, and generated-text definition. Each boundary projects only the fields
projections remain equivalent to that identity while each retains its own it needs from that prepared authority.
boundary-specific shape.
Preparation deep-copies mutable facts, snapshots, identity, metadata, and Preparation deep-copies mutable facts, snapshots, identity, and data-package
data-package bytes before returning them. Consumers receive independent copies bytes before returning them. Consumers receive independent copies so one
so one execution cannot change another's input or rendering context. execution cannot change another's input or rendering context.
Single-report generation executes one prepared profile and publishes its Single-report generation executes one prepared profile and publishes its
Markdown. Comparison prepares once, gives every selected profile the same YAML Markdown. Comparison prepares once, gives every selected profile the same YAML

View File

@@ -4,7 +4,7 @@
## Package Construction ## 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). 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).

View File

@@ -66,6 +66,7 @@ type BatchRequest struct {
type ModuleSnapshotRequest struct { type ModuleSnapshotRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
Identity briefing.PreparedIdentity
} }
type ReportFacts struct { type ReportFacts struct {
@@ -729,7 +730,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
if err != nil { if err != nil {
return module.Snapshot{}, err return module.Snapshot{}, err
} }
identity := req.Identity
if identity.ReportID == "" {
identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
}
moduleContext := briefing.ModuleContext{ moduleContext := briefing.ModuleContext{
Identity: identity,
Resolved: req.Resolved, Resolved: req.Resolved,
Collected: reportFacts.Collected, Collected: reportFacts.Collected,
Derived: reportFacts.Derived, 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{ return promptinput.Metadata{
RunID: metadata.RunID, RunID: identity.RunID,
ReportID: metadata.ReportID, ReportID: identity.ReportID,
Variant: metadata.Variant, Variant: identity.Variant,
PromptID: metadata.PromptID, PromptID: identity.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: identity.GeneratedAt,
Timezone: metadata.Timezone, Timezone: identity.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: identity.ValidPeriod,
SourceWarnings: metadata.SourceWarnings, SourceWarnings: identity.SourceWarnings,
} }
} }

View File

@@ -17,14 +17,13 @@ import (
// preparedReport contains the immutable deterministic inputs shared by prompt // preparedReport contains the immutable deterministic inputs shared by prompt
// executions for one resolved report. // executions for one resolved report.
type preparedReport struct { type preparedReport struct {
resolved report.Resolved resolved report.Resolved
reportFacts ReportFacts reportFacts ReportFacts
moduleSnapshot module.Snapshot moduleSnapshot module.Snapshot
identity briefing.PreparedIdentity identity briefing.PreparedIdentity
briefingMetadata briefing.Metadata sourceWarnings []weatherdata.SourceWarning
sourceWarnings []weatherdata.SourceWarning dataPackage []byte
dataPackage []byte handler generatedtext.Handler
handler generatedtext.Handler
} }
type prepareReportRequest struct { type prepareReportRequest struct {
@@ -57,12 +56,11 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
} }
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected) buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
identity := briefing.BuildPreparedIdentity(buildContext) 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 { if err != nil {
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err} return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
} }
metadata := briefing.BuildMetadata(identity, buildContext.Bundle) dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot})
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "build data package", err: err} return preparedReport{}, &preparationError{operation: "build data package", err: err}
} }
@@ -87,19 +85,14 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err} 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{ prepared := preparedReport{
resolved: cloneResolved(req.Resolved), resolved: cloneResolved(req.Resolved),
reportFacts: clonedFacts, reportFacts: clonedFacts,
moduleSnapshot: clonedSnapshot, moduleSnapshot: clonedSnapshot,
identity: clonedIdentity, identity: clonedIdentity,
briefingMetadata: clonedMetadata, sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), dataPackage: append([]byte(nil), serializedDataPackage...),
dataPackage: append([]byte(nil), serializedDataPackage...), handler: handler,
handler: handler,
} }
return prepared, nil return prepared, nil
} }
@@ -127,20 +120,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...) return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
} }
func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) { func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, ReportFacts, error) {
metadata, err := clonePreparedValue(p.briefingMetadata) identity, err := clonePreparedValue(p.identity)
if err != nil { if err != nil {
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err
} }
snapshot, err := clonePreparedValue(p.moduleSnapshot) snapshot, err := clonePreparedValue(p.moduleSnapshot)
if err != nil { if err != nil {
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err
} }
reportFacts, err := clonePreparedValue(p.reportFacts) reportFacts, err := clonePreparedValue(p.reportFacts)
if err != nil { 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) { func clonePreparedValue[T any](value T) (T, error) {

View File

@@ -31,19 +31,18 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("second prepareReport() error = %v", err) 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) { if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
t.Fatalf("prepared package/identity/metadata are not deterministic: %q/%#v/%#v", prepared.dataPackage, prepared.identity, prepared.briefingMetadata) t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
} }
originalDataPackage := append([]byte(nil), prepared.dataPackage...) originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity originalIdentity := prepared.identity
originalMetadata := prepared.briefingMetadata
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
metadata, snapshot, reportFacts, err := prepared.renderInputs() identity, snapshot, reportFacts, err := prepared.renderInputs()
if err != nil { if err != nil {
t.Fatalf("renderInputs() error = %v", err) 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 snapshot.Outputs = nil
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation" reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation"
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation" bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
@@ -55,7 +54,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
bundle.Sources[0].Query["mutated"] = "true" 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) t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
} }
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { 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() cfg := generationConfig()
bundle := generationBundle(t) bundle := generationBundle(t)
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
@@ -82,12 +81,14 @@ func TestPrepareReportSharesOneMetadataIdentity(t *testing.T) {
} }
identity := prepared.identity identity := prepared.identity
metadata := prepared.briefingMetadata renderIdentity, _, _, err := prepared.renderInputs()
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) { if err != nil {
t.Fatalf("briefing metadata does not match prepared identity: %#v/%#v", metadata, identity) t.Fatalf("renderInputs() error = %v", err)
} }
if !reflect.DeepEqual(renderIdentity, identity) {
prompt := promptMetadata(metadata) 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) { 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) t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
} }

View File

@@ -115,11 +115,11 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
if err != nil { if err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err} 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 { if err != nil {
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err} 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 { if err != nil {
return outcome, nil, &profileExecutionError{operation: "build render context", err: err} return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
} }

View File

@@ -600,7 +600,7 @@ func testModuleContext() ModuleContext {
hourlyHumidity := 66.0 hourlyHumidity := 66.0
hourlyWindMph := 14.0 hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{ ctx := ModuleContext{
Resolved: resolved, Resolved: resolved,
Collected: facts.CollectedFacts{ Collected: facts.CollectedFacts{
Current: &weatherdata.Current{ Current: &weatherdata.Current{
@@ -728,6 +728,14 @@ func testModuleContext() ModuleContext {
Timezone: "America/Chicago", 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 { func moduleValue[T any](t *testing.T, output *module.Output) T {

View File

@@ -31,18 +31,17 @@ type SourceWarningSummary struct {
} }
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
metadata := ctx.Resolved.Metadata()
value := MetadataModule{ value := MetadataModule{
RunID: metadata.RunID, RunID: ctx.Identity.RunID,
ReportID: metadata.ReportID, ReportID: ctx.Identity.ReportID,
Variant: variantForReport(metadata.ReportID), Variant: ctx.Identity.Variant,
PromptID: metadata.PromptID, PromptID: ctx.Identity.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: ctx.Identity.GeneratedAt,
Units: ctx.Units, Units: ctx.Identity.Units,
Timezone: ctx.Timezone, Timezone: ctx.Identity.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: ctx.Identity.ValidPeriod,
Location: copyLocation(ctx.Location), Location: copyLocation(ctx.Identity.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings),
} }
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
} }

View File

@@ -11,6 +11,7 @@ import (
) )
type ModuleContext struct { type ModuleContext struct {
Identity PreparedIdentity
Resolved report.Resolved Resolved report.Resolved
Collected facts.CollectedFacts Collected facts.CollectedFacts
Derived facts.DerivedFacts Derived facts.DerivedFacts

View File

@@ -9,7 +9,9 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "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"` RunID string `json:"runId"`
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
@@ -21,24 +23,7 @@ type Metadata struct {
Location *LocationContext `json:"location,omitempty"` Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,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 { type LocationContext struct {
@@ -48,24 +33,6 @@ type LocationContext struct {
Timezone string `json:"timezone,omitempty"` 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 { type BuildContext struct {
Resolved report.Resolved Resolved report.Resolved
Bundle *weatherdata.Bundle Bundle *weatherdata.Bundle
@@ -76,37 +43,20 @@ type BuildContext struct {
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity { func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
metadata := ctx.Resolved.Metadata() metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return PreparedIdentity{ return PreparedIdentity{
RunID: metadata.RunID, RunID: metadata.RunID,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID), Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID, PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units, Units: ctx.Units,
Timezone: ctx.Timezone, Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location), 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),
SourceLocationID: sourceLocationID, SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation, SourceLocation: sourceLocation,
Sources: sourceMetadata(bundle), SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), identity.SourceWarnings...),
Alerts: alertStatus(bundle),
} }
} }
@@ -165,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
return "", "" 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 { func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil { if bundle == nil {
return nil return nil
@@ -192,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
return bundle.Warnings 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 { func variantForReport(id report.ID) string {
switch id { switch id {
case report.Daily, report.Today: case report.Daily, report.Today:

View File

@@ -3,9 +3,11 @@ package briefing
import ( import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) { func TestPreparedIdentityCopiesMutableFields(t *testing.T) {
moduleContext := testModuleContext() moduleContext := testModuleContext()
context := BuildContext{ context := BuildContext{
Resolved: moduleContext.Resolved, Resolved: moduleContext.Resolved,
@@ -15,18 +17,10 @@ func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) {
Location: moduleContext.Location, Location: moduleContext.Location,
} }
identity := BuildPreparedIdentity(context) identity := BuildPreparedIdentity(context)
metadata := BuildMetadata(identity, context.Bundle) originalWarnings := append([]weatherdata.SourceWarning(nil), moduleContext.Collected.SourceWarnings...)
identity.Location.Name = "consumer mutation"
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 { identity.SourceWarnings = append(identity.SourceWarnings, moduleContext.Collected.SourceWarnings[0])
t.Fatalf("metadata identity = %#v, want %#v", metadata, identity) if moduleContext.Location.Name == "consumer mutation" || !reflect.DeepEqual(moduleContext.Collected.SourceWarnings, originalWarnings) {
} t.Fatalf("prepared identity changed its source context: %#v", 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)
} }
} }

View File

@@ -25,7 +25,7 @@ const (
type validator func([]byte) (any, []byte, error) 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 { type catalogEntry struct {
reportID report.ID reportID report.ID
@@ -140,11 +140,11 @@ func (h Handler) Validate(data []byte) (any, []byte, error) {
return h.validate(data) 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 { 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 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) { func (h Handler) Render(data any) ([]byte, error) {
@@ -171,34 +171,34 @@ func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data) 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) hourly, ok := generated.(Hourly)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID) 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) daily, ok := generated.(Daily)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID) 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) today, ok := generated.(Today)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID) 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) tomorrow, ok := generated.(Tomorrow)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID) 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)
} }

View File

@@ -188,15 +188,15 @@ type dayStyleTemplateModules struct {
WeatherStory *briefing.WeatherStoryModule WeatherStory *briefing.WeatherStoryModule
} }
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(identity.Timezone)
if err != nil { if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) 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") 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") return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
} }
modules, err := hourlyTemplateModules(snapshot) modules, err := hourlyTemplateModules(snapshot)
@@ -206,12 +206,12 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
return HourlyRenderContext{ return HourlyRenderContext{
Report: HourlyReportContext{ Report: HourlyReportContext{
Title: "Hourly Report", Title: "Hourly Report",
LocationName: locationName(metadata), LocationName: locationName(identity),
GeneratedAt: metadata.GeneratedAt, GeneratedAt: identity.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), GeneratedAtLabel: generatedAtLabel(identity.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod, ValidPeriod: identity.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), ValidPeriodLabel: periodLabel(identity.ValidPeriod, location),
Timezone: metadata.Timezone, Timezone: identity.Timezone,
}, },
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
@@ -220,8 +220,8 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}, nil }, nil
} }
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string { reportContext, err := buildDayStyleReportContext(identity, "daily", func(dayName string) string {
return dayName + "'s Weather" return dayName + "'s Weather"
}) })
if err != nil { if err != nil {
@@ -240,8 +240,8 @@ func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
}, nil }, nil
} }
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string { reportContext, err := buildDayStyleReportContext(identity, "today", func(string) string {
return "Today's Weather" return "Today's Weather"
}) })
if err != nil { if err != nil {
@@ -260,8 +260,8 @@ func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
}, nil }, nil
} }
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string { reportContext, err := buildDayStyleReportContext(identity, "tomorrow", func(dayName string) string {
return dayName + "'s Weather" return dayName + "'s Weather"
}) })
if err != nil { if err != nil {
@@ -280,29 +280,29 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil }, nil
} }
func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) { func buildDayStyleReportContext(identity briefing.PreparedIdentity, name string, title func(string) string) (dayStyleReportContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(identity.Timezone)
if err != nil { if err != nil {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err) 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) 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) 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") forecastDayName := forecastDate.Format("Monday")
return dayStyleReportContext{ return dayStyleReportContext{
Title: title(forecastDayName), Title: title(forecastDayName),
ForecastDate: forecastDate, ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName, ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: identity.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), GeneratedAtLabel: generatedAtLabel(identity.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod, ValidPeriod: identity.ValidPeriod,
Timezone: metadata.Timezone, Timezone: identity.Timezone,
}, nil }, nil
} }
@@ -706,20 +706,20 @@ func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key} return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
} }
func locationName(metadata briefing.Metadata) string { func locationName(identity briefing.PreparedIdentity) string {
if metadata.Location != nil { if identity.Location != nil {
if metadata.Location.Name != "" && metadata.Location.Region != "" { if identity.Location.Name != "" && identity.Location.Region != "" {
return metadata.Location.Name + ", " + metadata.Location.Region return identity.Location.Name + ", " + identity.Location.Region
} }
if metadata.Location.Name != "" { if identity.Location.Name != "" {
return metadata.Location.Name return identity.Location.Name
} }
} }
if metadata.SourceLocation != "" { if identity.SourceLocation != "" {
return metadata.SourceLocation return identity.SourceLocation
} }
if metadata.SourceLocationID != "" { if identity.SourceLocationID != "" {
return metadata.SourceLocationID return identity.SourceLocationID
} }
return "Unknown location" return "Unknown location"
} }

View File

@@ -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) generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.PreparedIdentity{
RunID: "run-1", RunID: "run-1",
ReportID: report.Hourly, ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text", 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) generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.PreparedIdentity{
RunID: "run-tomorrow", RunID: "run-tomorrow",
ReportID: report.Tomorrow, ReportID: report.Tomorrow,
PromptID: "weather.tomorrow_generated_text", 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) generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.PreparedIdentity{
RunID: "run-daily", RunID: "run-daily",
ReportID: report.Daily, ReportID: report.Daily,
PromptID: "weather.daily_generated_text", 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) generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC)
return briefing.Metadata{ return briefing.PreparedIdentity{
RunID: "run-today", RunID: "run-today",
ReportID: report.Today, ReportID: report.Today,
PromptID: "weather.today_generated_text", PromptID: "weather.today_generated_text",