From 8a762bf34f77703a595c51f6c58772230ba7ffd9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 19:57:08 -0500 Subject: [PATCH] Added a location block to the data package --- docs/config.md | 13 +++++++++++++ docs/internal/briefing.md | 6 ++++-- docs/internal/prompt-input.md | 8 ++++---- docs/internal/state.md | 4 ++-- examples/config.yml | 5 +++++ internal/app/app.go | 17 +++++++++++++++++ internal/app/app_test.go | 11 +++++++++++ internal/briefing/daily_test.go | 9 +++++++++ internal/briefing/package.go | 18 ++++++++++++++++++ internal/cli/root_test.go | 19 +++++++++++++++++++ internal/config/config.go | 7 +++++++ internal/config/config_test.go | 9 +++++++++ internal/config/defaults.go | 5 +++++ internal/promptinput/package_test.go | 9 +++++++++ internal/state/filesystem_test.go | 11 ++++++++++- internal/state/metadata.go | 10 ++++++++++ 16 files changed, 152 insertions(+), 9 deletions(-) diff --git a/docs/config.md b/docs/config.md index 9b1b826..2033792 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,19 @@ config test suite. Timezone values may be IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`. +### `location` + +`location` is descriptive prompt context included in briefing metadata and +Scriptorium data packages. It does not select a Weather API endpoint or enable +multiple configured forecast locations. + +- `id`: short local identifier. Default: `home`. +- `name`: human-readable location name. Default: `Brentwood`. +- `region`: broader forecast area context. Default: `St. Louis Metro`. + +The prompt-facing location object also includes `timezone`, derived from the +effective `weather_api.timezone` after CLI overrides such as `--tz`. + ### `missing_source` - `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`. diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 7a95765..093c93a 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -16,7 +16,7 @@ Inputs: - resolved report definition, generation time, timezone, and valid period - forecast bundle with source provenance and warnings - derived daily or period summaries where required -- configured units and timezone +- configured units, timezone, and descriptive location context Outputs: @@ -34,6 +34,8 @@ Outputs: The package receives configured units and timezone from the app layer. Daypart configuration is consumed by `internal/forecast` before briefing builders run. +Configured `location` values are prompt context only; Weather API +`sourceLocationId` and `sourceLocation` remain source provenance. ## External Adapters Used @@ -74,6 +76,6 @@ Inspect: - Briefings contain structured weather facts and source context. - Common metadata includes RunID, report ID, prompt ID, valid period, source - provenance, source hashes, and source warnings. + provenance, source hashes, source warnings, and configured prompt location. - LLM prompt input packaging and Scriptorium execution remain outside this boundary. diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 70e7f66..c29e33f 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -18,8 +18,8 @@ Outputs: - `promptinput.Package` containing schema version, RunID, report metadata, briefing content, Recent Changes, and source warnings. Briefing content - includes discussion key messages and short/long-term AFD narratives when the - Weather API provides them. + includes configured location context, discussion key messages, and + short/long-term AFD narratives when the Weather API provides them. - optional JSON file written by `promptinput.Save` ## Boundaries @@ -30,8 +30,8 @@ Outputs: ## Config Fields Used -None directly. Config-derived values are already present in briefing metadata -before this package runs. +None directly. Config-derived values, including prompt location context, are +already present in briefing metadata before this package runs. ## External Adapters Used diff --git a/docs/internal/state.md b/docs/internal/state.md index 92f3b6c..85e613d 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -66,8 +66,8 @@ valid-period start date for JSON artifacts, and the RunID. ``` Metadata is stored beside briefing snapshots and links the briefing, data -package, preflight, and report paths. Report listing walks metadata files under -the snapshots directory. +package, preflight, report paths, and configured prompt location. Report +listing walks metadata files under the snapshots directory. ## Prior Lookup diff --git a/examples/config.yml b/examples/config.yml index 1434640..083bdb6 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -6,6 +6,11 @@ weather_api: timezone: "America/Chicago" format: json +location: + id: home + name: Brentwood + region: St. Louis Metro + missing_source: default: warn sources: diff --git a/internal/app/app.go b/internal/app/app.go index 35b3df7..a253731 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -522,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, + Location: briefingLocation(req.Config), }, summary) case report.ThreeDay, report.Weekend: summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) @@ -534,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, + Location: briefingLocation(req.Config), }, summaries) } return briefing.BuildThreeDay(briefing.BuildContext{ @@ -541,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, + Location: briefingLocation(req.Config), }, summaries) case report.Storm: return briefing.BuildStorm(briefing.BuildContext{ @@ -548,12 +551,26 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, + Location: briefingLocation(req.Config), }) default: return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID) } } +func briefingLocation(cfg config.Config) *briefing.LocationContext { + location := briefing.LocationContext{ + ID: cfg.Location.ID, + Name: cfg.Location.Name, + Region: cfg.Location.Region, + Timezone: cfg.WeatherAPI.Timezone, + } + if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" { + return nil + } + return &location +} + func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { return state.NewFilesystemStore(cfg.Workspace) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ad5a765..27ce4d8 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -195,6 +196,16 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { t.Fatalf("data package missing expected content:\n%s", string(data)) } + var savedDataPackage struct { + Briefing briefing.Package `json:"briefing"` + } + if err := json.Unmarshal(data, &savedDataPackage); err != nil { + t.Fatalf("decode data package: %v", err) + } + location := savedDataPackage.Briefing.Metadata.Location + if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" { + t.Fatalf("data package location = %#v, want configured prompt location", location) + } if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data)) } diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go index efaf80d..a54d3e8 100644 --- a/internal/briefing/daily_test.go +++ b/internal/briefing/daily_test.go @@ -29,6 +29,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { Bundle: bundle, Units: "us", Timezone: "America/Chicago", + Location: &LocationContext{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + Timezone: "America/Chicago", + }, }, summary) if err != nil { t.Fatalf("BuildDaily() error = %v", err) @@ -46,6 +52,9 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) } + if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" { + t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location) + } if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) } diff --git a/internal/briefing/package.go b/internal/briefing/package.go index 1f85cda..a85db05 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -31,6 +31,7 @@ type Metadata struct { Units string `json:"units"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` + Location *LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"` @@ -38,6 +39,13 @@ type Metadata struct { Alerts *AlertStatus `json:"alerts,omitempty"` } +type LocationContext struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Region string `json:"region,omitempty"` + Timezone string `json:"timezone,omitempty"` +} + type SourceMetadata struct { Name string `json:"name"` Endpoint string `json:"endpoint,omitempty"` @@ -61,6 +69,7 @@ type BuildContext struct { Bundle *forecast.Bundle Units string Timezone string + Location *LocationContext } func BuildMetadata(ctx BuildContext) Metadata { @@ -76,6 +85,7 @@ func BuildMetadata(ctx BuildContext) Metadata { Units: ctx.Units, Timezone: ctx.Timezone, ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(ctx.Location), SourceLocationID: sourceLocationID, SourceLocation: sourceLocation, Sources: sourceMetadata(ctx.Bundle), @@ -84,6 +94,14 @@ func BuildMetadata(ctx BuildContext) Metadata { } } +func copyLocation(location *LocationContext) *LocationContext { + if location == nil { + return nil + } + copied := *location + return &copied +} + func Save(path string, pkg Package) error { if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { return fmt.Errorf("save briefing package: %w", err) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index c6233c6..2ebae8d 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -437,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { "generate", "daily", "--config", configPath, "--date", "2026-05-29", + "--tz", "UTC", "--out", outPath, }, &stdout, &stderr) if err != nil { @@ -463,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { t.Fatalf("data package output missing expected content:\n%s", string(data)) } + var decoded struct { + Briefing struct { + Metadata struct { + Location struct { + ID string `json:"id"` + Name string `json:"name"` + Region string `json:"region"` + Timezone string `json:"timezone"` + } `json:"location"` + } `json:"metadata"` + } `json:"briefing"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decode data package: %v", err) + } + if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" { + t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location) + } preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) if err != nil { t.Fatalf("glob preflight: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index e9cb8df..2972bf4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ const ( type Config struct { WeatherAPI WeatherAPIConfig `yaml:"weather_api"` + Location LocationConfig `yaml:"location"` MissingSource MissingSourceConfig `yaml:"missing_source"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Workspace WorkspaceConfig `yaml:"workspace"` @@ -30,6 +31,12 @@ type WeatherAPIConfig struct { Format string `yaml:"format"` } +type LocationConfig struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Region string `yaml:"region"` +} + type MissingSourceConfig struct { Default MissingSourcePolicy `yaml:"default"` Sources map[string]MissingSourcePolicy `yaml:"sources"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a989661..c6b7f60 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,6 +23,9 @@ func TestDefaults(t *testing.T) { if cfg.WeatherAPI.Format != "json" { t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format) } + if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { + t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location) + } if cfg.MissingSource.Default != MissingSourceWarn { t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) } @@ -43,6 +46,9 @@ func TestLoadExampleConfig(t *testing.T) { if cfg.MissingSource.Sources["alerts"] != MissingSourceNone { t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"]) } + if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { + t.Fatalf("Location = %#v, want example location", cfg.Location) + } } func TestLoadMinimalExampleConfig(t *testing.T) { @@ -63,6 +69,9 @@ func TestLoadMinimalExampleConfig(t *testing.T) { if cfg.Workspace.Root != "workspace" { t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root) } + if cfg.Location.Name != "Brentwood" { + t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name) + } } func TestExplicitMissingConfigReturnsError(t *testing.T) { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 778050a..ddb9803 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -13,6 +13,11 @@ func Defaults() Config { Timezone: "America/Chicago", Format: "json", }, + Location: LocationConfig{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + }, MissingSource: MissingSourceConfig{ Default: MissingSourceWarn, Sources: map[string]MissingSourcePolicy{}, diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index cba7399..b6e81da 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -31,6 +31,9 @@ func TestBuildDailyDataPackage(t *testing.T) { if pkg.Briefing.Daily == nil { t.Fatal("Briefing.Daily = nil") } + if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" { + t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location) + } if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) } @@ -155,6 +158,12 @@ func validBriefingPackage() briefing.Package { GeneratedAt: generatedAt, Units: "us", Timezone: "America/Chicago", + Location: &briefing.LocationContext{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + Timezone: "America/Chicago", + }, ValidPeriod: timeutil.Period{ Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index e9b5d06..97192f6 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -122,6 +122,9 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if decoded.RenderedReportPath != renderedReportPath { t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath) } + if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" { + t.Fatalf("metadata location = %#v, want briefing location", decoded.Location) + } if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) } @@ -437,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package { GeneratedAt: resolved.GeneratedAt, Units: "us", Timezone: resolved.Timezone, - ValidPeriod: resolved.ValidPeriod, + Location: &briefing.LocationContext{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + Timezone: resolved.Timezone, + }, + ValidPeriod: resolved.ValidPeriod, }, Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"}, } diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 6bb99f6..fc55d2f 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -21,6 +21,7 @@ type Metadata struct { GeneratedAt time.Time `json:"generatedAt"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` + Location *briefing.LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"` @@ -43,6 +44,7 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p GeneratedAt: metadata.GeneratedAt, Timezone: metadata.Timezone, ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(briefingPackage.Metadata.Location), SourceLocationID: briefingPackage.Metadata.SourceLocationID, SourceLocation: briefingPackage.Metadata.SourceLocation, Sources: briefingPackage.Metadata.Sources, @@ -53,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p RenderedReportPath: paths.RenderedReport, } } + +func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { + if location == nil { + return nil + } + copied := *location + return &copied +}