Write prompt data packages as YAML

This commit is contained in:
2026-06-09 21:08:17 +00:00
parent 2483c2362d
commit 0b516d9762
11 changed files with 394 additions and 255 deletions

View File

@@ -55,13 +55,13 @@ Workspace subdirectories must be relative paths that stay under
## Managed Layout ## Managed Layout
Paths are derived from the resolved report definition's artifact group, the Paths are derived from the resolved report definition's artifact group, the
valid-period start date for JSON artifacts, and the RunID. valid-period start date for dated artifacts, and the RunID.
```text ```text
<workspace.root>/ <workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
reports/<artifact_group>/<run_id>.md reports/<artifact_group>/<run_id>.md

View File

@@ -72,16 +72,16 @@ workspace/
data-packages/ data-packages/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
preflight/ preflight/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/

View File

@@ -20,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json", "--input", "data_package=/tmp/data_package.yaml",
"--format", "json", "--format", "json",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
@@ -57,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err == nil { if err == nil {
t.Fatal("Render() error = nil, want nonzero exit error") t.Fatal("Render() error = nil, want nonzero exit error")
@@ -85,7 +85,7 @@ func TestRunConstructsCommand(t *testing.T) {
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
if err != nil { if err != nil {
@@ -97,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json", "--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md", "--out", "/tmp/daily.md",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
@@ -130,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
if err == nil { if err == nil {

View File

@@ -522,7 +522,19 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges) metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recentChanges,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -530,6 +542,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
metadata.DataPackagePath = dataPackagePath
renderer := req.Renderer renderer := req.Renderer
if renderer == nil { if renderer == nil {
@@ -554,14 +567,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
} }
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{ metadata.PreflightPath = preflightPath
Briefing: briefingPath,
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata) metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil { if metadataErr != nil {
return nil, metadataErr return nil, metadataErr
@@ -894,6 +900,19 @@ func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle)
return module.NewSnapshot(outputs) return module.NewSnapshot(outputs)
} }
func promptMetadata(metadata state.Metadata) 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,
}
}
func buildDerivedFacts(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) (facts.DerivedFacts, error) { func buildDerivedFacts(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) (facts.DerivedFacts, error) {
dayparts := make([]forecast.DaypartDefinition, 0, len(cfg.Dayparts)) dayparts := make([]forecast.DaypartDefinition, 0, len(cfg.Dayparts))
for _, daypart := range cfg.Dayparts { for _, daypart := range cfg.Dayparts {

View File

@@ -17,6 +17,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
) )
@@ -206,31 +207,32 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "area_forecast_discussion:") {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
var savedDataPackage struct { savedDataPackage, err := promptinput.LoadYAML(data)
Report struct { if err != nil {
CurrentLocalDate string `json:"currentLocalDate"`
} `json:"report"`
Briefing briefing.Package `json:"briefing"`
}
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
t.Fatalf("decode data package: %v", err) t.Fatalf("decode data package: %v", err)
} }
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" { if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate) t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
} }
location := savedDataPackage.Briefing.Metadata.Location if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" { t.Fatal("data package metadata stanza missing")
t.Fatalf("data package location = %#v, want configured prompt location", location)
} }
current := savedDataPackage.Briefing.CurrentConditions current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 { if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", current) t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
} }
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" { story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily) if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
} }
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { 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)) t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
@@ -1012,7 +1014,7 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
t.Fatalf("data package missing storm content:\n%s", string(data)) t.Fatalf("data package missing storm content:\n%s", string(data))
} }
} }

View File

@@ -90,12 +90,12 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json") dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") {
t.Fatalf("data package output missing storm content:\n%s", string(data)) t.Fatalf("data package output missing storm content:\n%s", string(data))
} }
} }
@@ -126,12 +126,12 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json") dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) { if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
t.Fatalf("data package output missing tomorrow content:\n%s", string(data)) t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
} }
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
@@ -164,7 +164,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -210,7 +210,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -221,7 +221,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) { if !strings.Contains(string(data), "id: three_day") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing 3-day content:\n%s", string(data)) t.Fatalf("data package output missing 3-day content:\n%s", string(data))
} }
} }
@@ -256,7 +256,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -267,7 +267,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) { if !strings.Contains(string(data), "id: weekend") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing weekend content:\n%s", string(data)) t.Fatalf("data package output missing weekend content:\n%s", string(data))
} }
} }
@@ -293,7 +293,7 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob weekend packages: %v", err) t.Fatalf("glob weekend packages: %v", err)
} }
@@ -337,11 +337,11 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") { if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String()) t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
} }
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob daily packages: %v", err) t.Fatalf("glob daily packages: %v", err)
} }
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob weekend packages: %v", err) t.Fatalf("glob weekend packages: %v", err)
} }
@@ -593,11 +593,11 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json")) dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob daily packages: %v", err) t.Fatalf("glob daily packages: %v", err)
} }
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json")) threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob 3-day packages: %v", err) t.Fatalf("glob 3-day packages: %v", err)
} }
@@ -638,7 +638,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -649,26 +649,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") {
t.Fatalf("data package output missing expected content:\n%s", string(data)) t.Fatalf("data package output missing expected content:\n%s", string(data))
} }
var decoded struct { if !strings.Contains(string(data), "location:") ||
Briefing struct { !strings.Contains(string(data), "id: home") ||
Metadata struct { !strings.Contains(string(data), "name: Brentwood") ||
Location struct { !strings.Contains(string(data), "region: St. Louis Metro") ||
ID string `json:"id"` !strings.Contains(string(data), "timezone: UTC") {
Name string `json:"name"` t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
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")) preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil { if err != nil {
@@ -715,14 +704,14 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
if len(dataPackageMatches) != 1 { if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches) t.Fatalf("data package files = %#v, want one", dataPackageMatches)
} }
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json") runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
stdout.Reset() stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)

View File

@@ -1,72 +1,93 @@
// Package promptinput builds prompt data packages from briefing packages. // Package promptinput builds prompt data packages from module snapshots.
package promptinput package promptinput
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
"gopkg.in/yaml.v3"
) )
const SchemaVersion = "weatherreporter.data_package.v1" const SchemaVersion = "weatherreporter.data_package.v2"
type BuildRequest struct {
Metadata Metadata
Modules module.Snapshot
RecentChanges []changes.Change
}
type Metadata struct {
RunID string
ReportID report.ID
Variant string
PromptID string
GeneratedAt time.Time
Timezone string
ValidPeriod timeutil.Period
SourceWarnings []weatherdata.SourceWarning
}
type Package struct { type Package struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion" yaml:"schema_version"`
RunID string `json:"runId"` RunID string `json:"runId" yaml:"run_id"`
Report Report `json:"report"` Report Report `json:"report" yaml:"report"`
Briefing briefing.Package `json:"briefing"` Briefing BriefingStanzas `json:"briefing" yaml:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"` RecentChanges RecentChanges `json:"recentChanges" yaml:"recent_changes"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty" yaml:"source_warnings,omitempty"`
} }
type Report struct { type Report struct {
ID report.ID `json:"id"` ID report.ID `json:"id" yaml:"id"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty" yaml:"variant,omitempty"`
PromptID string `json:"promptId"` PromptID string `json:"promptId" yaml:"prompt_id"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt" yaml:"generated_at"`
Timezone string `json:"timezone"` Timezone string `json:"timezone" yaml:"timezone"`
CurrentLocalDate string `json:"currentLocalDate"` CurrentLocalDate string `json:"currentLocalDate" yaml:"current_local_date"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod" yaml:"valid_period"`
}
type BriefingStanzas struct {
Order []string `json:"-" yaml:"-"`
Values map[string]any `json:"-" yaml:"-"`
} }
type RecentChanges struct { type RecentChanges struct {
Items []changes.Change `json:"items"` Items []changes.Change `json:"items" yaml:"items"`
} }
func Build(briefingPackage briefing.Package) (Package, error) { func Build(req BuildRequest) (Package, error) {
return BuildWithRecentChanges(briefingPackage, nil) localDate, err := currentLocalDate(req.Metadata.GeneratedAt, req.Metadata.Timezone)
}
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
if err != nil { if err != nil {
return Package{}, err return Package{}, err
} }
items := make([]changes.Change, len(recentChanges)) items := make([]changes.Change, len(req.RecentChanges))
copy(items, recentChanges) copy(items, req.RecentChanges)
if items == nil { if items == nil {
items = []changes.Change{} items = []changes.Change{}
} }
pkg := Package{ pkg := Package{
SchemaVersion: SchemaVersion, SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID, RunID: req.Metadata.RunID,
Report: Report{ Report: Report{
ID: briefingPackage.Metadata.ReportID, ID: req.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: req.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID, PromptID: req.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt, GeneratedAt: req.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone, Timezone: req.Metadata.Timezone,
CurrentLocalDate: localDate, CurrentLocalDate: localDate,
ValidPeriod: briefingPackage.Metadata.ValidPeriod, ValidPeriod: req.Metadata.ValidPeriod,
}, },
Briefing: briefingPackage, Briefing: stanzasFromSnapshot(req.Modules),
RecentChanges: RecentChanges{Items: items}, RecentChanges: RecentChanges{Items: items},
SourceWarnings: briefingPackage.Metadata.SourceWarnings, SourceWarnings: append([]weatherdata.SourceWarning(nil), req.Metadata.SourceWarnings...),
} }
if err := Validate(pkg); err != nil { if err := Validate(pkg); err != nil {
return Package{}, err return Package{}, err
@@ -74,6 +95,16 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
return pkg, nil return pkg, nil
} }
func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
values := map[string]any{}
order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value
}
return BriefingStanzas{Order: order, Values: values}
}
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) { func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {
@@ -86,6 +117,9 @@ func Validate(pkg Package) error {
if pkg.SchemaVersion == "" { if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required") return fmt.Errorf("schemaVersion is required")
} }
if pkg.SchemaVersion != SchemaVersion {
return fmt.Errorf("schemaVersion must be %s", SchemaVersion)
}
if pkg.RunID == "" { if pkg.RunID == "" {
return fmt.Errorf("runId is required") return fmt.Errorf("runId is required")
} }
@@ -107,33 +141,134 @@ func Validate(pkg Package) error {
if !pkg.Report.ValidPeriod.IsValid() { if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid") return fmt.Errorf("report.validPeriod must be valid")
} }
if pkg.Briefing.Metadata.RunID == "" { if len(pkg.Briefing.Order) == 0 {
return fmt.Errorf("briefing.metadata.runId is required") return fmt.Errorf("briefing stanzas are required")
} }
if pkg.Briefing.Metadata.RunID != pkg.RunID { for _, name := range pkg.Briefing.Order {
return fmt.Errorf("briefing.metadata.runId must match runId") if name == "" {
} return fmt.Errorf("briefing stanza name is required")
if pkg.Briefing.Metadata.SchemaVersion == "" { }
return fmt.Errorf("briefing.metadata.schemaVersion is required") if _, ok := pkg.Briefing.Values[name]; !ok {
} return fmt.Errorf("briefing stanza %q is missing", name)
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID { }
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
}
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
}
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
return fmt.Errorf("briefing report content is required")
} }
return nil return nil
} }
func Save(path string, pkg Package) error { func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil { data, err := MarshalYAML(pkg)
if err != nil {
return err return err
} }
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { if err := fileutil.WriteFileAtomic(path, data); err != nil {
return fmt.Errorf("save data package: %w", err) return fmt.Errorf("save data package: %w", err)
} }
return nil return nil
} }
func MarshalYAML(pkg Package) ([]byte, error) {
if err := Validate(pkg); err != nil {
return nil, err
}
data, err := yaml.Marshal(pkg)
if err != nil {
return nil, fmt.Errorf("marshal data package: %w", err)
}
return data, nil
}
func LoadYAML(data []byte) (Package, error) {
var pkg Package
decoder := yaml.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&pkg); err != nil {
return Package{}, fmt.Errorf("decode data package: %w", err)
}
if err := Validate(pkg); err != nil {
return Package{}, err
}
return pkg, nil
}
func (b BriefingStanzas) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
for _, name := range b.Order {
value, ok := b.Values[name]
if !ok {
continue
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
}
node.Content = append(node.Content, keyNode, valueNode)
}
return node, nil
}
func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("briefing must be a mapping")
}
values := map[string]any{}
order := make([]string, 0, len(value.Content)/2)
for i := 0; i < len(value.Content); i += 2 {
name := value.Content[i].Value
var stanza any
if err := value.Content[i+1].Decode(&stanza); err != nil {
return err
}
order = append(order, name)
values[name] = stanza
}
b.Order = order
b.Values = values
return nil
}
func (b BriefingStanzas) MarshalJSON() ([]byte, error) {
out := map[string]any{}
for _, name := range b.Order {
if value, ok := b.Values[name]; ok {
out[name] = value
}
}
return json.Marshal(out)
}
func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
var values map[string]any
if err := json.Unmarshal(data, &values); err != nil {
return err
}
order := make([]string, 0, len(values))
for name := range values {
order = append(order, name)
}
b.Order = order
b.Values = values
return nil
}
func yamlNode(value any) (*yaml.Node, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized any
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
data, err = yaml.Marshal(normalized)
if err != nil {
return nil, err
}
var node yaml.Node
if err := yaml.Unmarshal(data, &node); err != nil {
return nil, err
}
if len(node.Content) == 0 {
return &yaml.Node{Kind: yaml.MappingNode}, nil
}
return node.Content[0], nil
}

View File

@@ -1,20 +1,17 @@
package promptinput package promptinput
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestBuildDailyDataPackage(t *testing.T) { func TestBuildDailyDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage() pkg, err := Build(validBuildRequest(t))
pkg, err := Build(briefingPackage)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -23,7 +20,7 @@ func TestBuildDailyDataPackage(t *testing.T) {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
} }
if pkg.RunID != "20260529T100000Z_daily_today" { if pkg.RunID != "20260529T100000Z_daily_today" {
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID) t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
} }
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
@@ -31,14 +28,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.Report.CurrentLocalDate != "2026-05-29" { if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
} }
if pkg.Briefing.Daily == nil { if pkg.Briefing.Order[0] != "metadata" || pkg.Briefing.Order[1] != "current_conditions" || pkg.Briefing.Order[2] != "derived_daily_summary" {
t.Fatal("Briefing.Daily = nil") t.Fatalf("Briefing.Order = %#v, want snapshot stanza order", pkg.Briefing.Order)
} }
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" { if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location) t.Fatalf("current_conditions.condition_text = %q, want Partly cloudy", got)
}
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
} }
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
@@ -46,11 +40,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
} }
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) { func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC) req.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
briefingPackage.Metadata.Timezone = "America/Chicago" req.Metadata.Timezone = "America/Chicago"
pkg, err := Build(briefingPackage) pkg, err := Build(req)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -61,10 +55,10 @@ func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
} }
func TestBuildRejectsInvalidReportTimezone(t *testing.T) { func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.Timezone = "Not/AZone" req.Metadata.Timezone = "Not/AZone"
_, err := Build(briefingPackage) _, err := Build(req)
if err == nil { if err == nil {
t.Fatal("Build() error = nil, want invalid timezone error") t.Fatal("Build() error = nil, want invalid timezone error")
} }
@@ -74,7 +68,7 @@ func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
} }
func TestValidateRequiresFields(t *testing.T) { func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -90,7 +84,7 @@ func TestValidateRequiresFields(t *testing.T) {
} }
func TestValidateRequiresCurrentLocalDate(t *testing.T) { func TestValidateRequiresCurrentLocalDate(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -105,17 +99,17 @@ func TestValidateRequiresCurrentLocalDate(t *testing.T) {
} }
} }
func TestBuildThreeDayDataPackage(t *testing.T) { func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day" req.Metadata.RunID = "20260529T100000Z_three_day"
briefingPackage.Metadata.ReportID = report.ThreeDay req.Metadata.ReportID = report.ThreeDay
briefingPackage.Metadata.PromptID = "weather.three_day_outlook" req.Metadata.PromptID = "weather.three_day_outlook"
briefingPackage.Daily = nil req.Modules = snapshotWithOutputs(t,
briefingPackage.ThreeDay = &briefing.ThreeDay{ module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
Days: []briefing.OutlookDay{{Date: "2026-05-29"}}, module.Output{ID: module.ForecastDelta, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
} )
pkg, err := Build(briefingPackage) pkg, err := Build(req)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -123,107 +117,88 @@ func TestBuildThreeDayDataPackage(t *testing.T) {
if pkg.Report.ID != report.ThreeDay { if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID) t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
} }
if pkg.Briefing.ThreeDay == nil { if _, ok := pkg.Briefing.Values["three_day"]; !ok {
t.Fatal("Briefing.ThreeDay = nil") t.Fatal("Briefing.Values[three_day] missing")
} }
} }
func TestBuildWeekendDataPackage(t *testing.T) { func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
briefingPackage := validBriefingPackage() pkg, err := Build(validBuildRequest(t))
briefingPackage.Metadata.RunID = "20260529T100000Z_weekend"
briefingPackage.Metadata.ReportID = report.Weekend
briefingPackage.Metadata.PromptID = "weather.weekend_outlook"
briefingPackage.Daily = nil
briefingPackage.Weekend = &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
}
pkg, err := Build(briefingPackage)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
if pkg.Report.ID != report.Weekend { first, err := MarshalYAML(pkg)
t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID)
}
if pkg.Briefing.Weekend == nil {
t.Fatal("Briefing.Weekend = nil")
}
}
func TestBuildStormDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
briefingPackage.Metadata.ReportID = report.Storm
briefingPackage.Metadata.PromptID = "weather.storm_report"
briefingPackage.Daily = nil
briefingPackage.Storm = &briefing.Storm{
TimingWindow: briefingPackage.Metadata.ValidPeriod,
Hazards: []string{"Thunderstorms"},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Storm {
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
}
if pkg.Report.PromptID != "weather.storm_report" {
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
}
if pkg.Briefing.Storm == nil {
t.Fatal("Briefing.Storm = nil")
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
first, err := json.MarshalIndent(pkg, "", " ")
if err != nil { if err != nil {
t.Fatalf("first marshal: %v", err) t.Fatalf("first marshal: %v", err)
} }
second, err := json.MarshalIndent(pkg, "", " ") second, err := MarshalYAML(pkg)
if err != nil { if err != nil {
t.Fatalf("second marshal: %v", err) t.Fatalf("second marshal: %v", err)
} }
if string(first) != string(second) { if string(first) != string(second) {
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second)) t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
}
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " current_conditions:\n") ||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
} }
} }
func validBriefingPackage() briefing.Package { func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
}
if loaded.Briefing.Order[1] != "current_conditions" {
t.Fatalf("loaded briefing order = %#v, want current_conditions second", loaded.Briefing.Order)
}
}
func validBuildRequest(t *testing.T) BuildRequest {
t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return briefing.Package{ return BuildRequest{
Metadata: briefing.Metadata{ Metadata: Metadata{
SchemaVersion: briefing.SchemaVersion, RunID: "20260529T100000Z_daily_today",
RunID: "20260529T100000Z_daily_today", ReportID: report.DailyToday,
ReportID: report.DailyToday, Variant: "today",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Units: "us", Timezone: "America/Chicago",
Timezone: "America/Chicago",
Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
}, },
}, },
CurrentConditions: &briefing.CurrentConditionsContext{ Modules: snapshotWithOutputs(t,
ConditionText: "Partly cloudy", module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
Daily: &briefing.Daily{ module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
ForecastSummaryDate: "2026-05-29", ),
},
} }
} }
func snapshotWithOutputs(t *testing.T, outputs ...module.Output) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}

View File

@@ -95,7 +95,7 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"), Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"), DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
@@ -132,10 +132,7 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
if err != nil { if err != nil {
return "", err return "", err
} }
if err := promptinput.Validate(pkg); err != nil { if err := promptinput.Save(paths.DataPackage, pkg); err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err return "", err
} }
return paths.DataPackage, nil return paths.DataPackage, nil
@@ -321,8 +318,12 @@ func (s *FilesystemStore) LoadDataPackage(_ context.Context, path string) (promp
if path == "" { if path == "" {
return promptinput.Package{}, fmt.Errorf("data package path is required") return promptinput.Package{}, fmt.Errorf("data package path is required")
} }
var pkg promptinput.Package data, err := os.ReadFile(path)
if err := readJSON(path, &pkg); err != nil { if err != nil {
return promptinput.Package{}, fmt.Errorf("read %q: %w", path, err)
}
pkg, err := promptinput.LoadYAML(data)
if err != nil {
return promptinput.Package{}, err return promptinput.Package{}, err
} }
return pkg, nil return pkg, nil

View File

@@ -30,7 +30,7 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.json"), filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"), filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"), filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
@@ -45,7 +45,22 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(resolved) briefingPackage := stateBriefingPackage(resolved)
dataPackage, err := promptinput.Build(briefingPackage) snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptinput.Metadata{
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
Variant: briefingPackage.Metadata.Variant,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Modules: snapshot,
})
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -58,10 +73,6 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err) t.Fatalf("SaveDataPackage() error = %v", err)
} }
snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot) moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
if err != nil { if err != nil {
t.Fatalf("SaveModuleSnapshot() error = %v", err) t.Fatalf("SaveModuleSnapshot() error = %v", err)
@@ -152,6 +163,13 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 { if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot) t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
} }
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
if err != nil {
t.Fatalf("LoadDataPackage() error = %v", err)
}
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
}
var decoded Metadata var decoded Metadata
data, err := os.ReadFile(metadataPath) data, err := os.ReadFile(metadataPath)
if err != nil { if err != nil {

View File

@@ -33,12 +33,12 @@ type Source struct {
} }
type SourceWarning struct { type SourceWarning struct {
Source string `json:"source"` Source string `json:"source" yaml:"source"`
Code string `json:"code"` Code string `json:"code" yaml:"code"`
Severity string `json:"severity"` Severity string `json:"severity" yaml:"severity"`
Message string `json:"message"` Message string `json:"message" yaml:"message"`
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
CompletenessImpact string `json:"completenessImpact,omitempty"` CompletenessImpact string `json:"completenessImpact,omitempty" yaml:"completeness_impact,omitempty"`
} }
type Observation struct { type Observation struct {