From e556ca5edc7584c816975df89f0e3b73dc7e3a96 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:32:57 +0000 Subject: [PATCH] Add Daily prompt input preflight --- README.md | 8 +- docs/cli.md | 22 +-- docs/internal/prompt-input.md | 52 +++++++ docs/internal/scriptorium-adapter.md | 63 ++++++++ internal/adapters/scriptorium/runner.go | 154 +++++++++++++++++++ internal/adapters/scriptorium/runner_test.go | 89 +++++++++++ internal/app/app.go | 107 ++++++++++++- internal/app/app_test.go | 119 ++++++++++++++ internal/cli/root.go | 4 +- internal/cli/root_test.go | 42 ++++- internal/promptinput/package.go | 140 +++++++++++++++++ internal/promptinput/package_test.go | 94 +++++++++++ 12 files changed, 868 insertions(+), 26 deletions(-) create mode 100644 docs/internal/prompt-input.md create mode 100644 docs/internal/scriptorium-adapter.md create mode 100644 internal/adapters/scriptorium/runner.go create mode 100644 internal/adapters/scriptorium/runner_test.go create mode 100644 internal/promptinput/package.go create mode 100644 internal/promptinput/package_test.go diff --git a/README.md b/README.md index 2f5ba89..c9d6a81 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently produce a Daily briefing JSON artifact. Rendered -reports and `scriptorium` execution are tracked in the roadmap and are not -implemented yet. +The application can currently prepare a Daily prompt input data package and run +`scriptorium render` as a preflight check. Full rendered reports are tracked in +the roadmap and are not implemented yet. ## Quickstart ```sh -weatherreporter generate daily --date 2026-05-29 --out ./daily.md +weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json ``` ## Documentation diff --git a/docs/cli.md b/docs/cli.md index 61754ec..b160d8c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,17 +1,19 @@ # Weatherreporter CLI -`weatherreporter generate daily` currently writes a Daily briefing JSON artifact. -Other report generation and scheduled runs still resolve configuration, report -definitions, and valid periods, then return a not-implemented error. +`weatherreporter generate daily` currently writes a Daily prompt input data +package and runs `scriptorium render` as a preflight check. Other report +generation and scheduled runs still resolve configuration, report definitions, +and valid periods, then return a not-implemented error. ## Shortest Useful Command ```sh -weatherreporter generate daily --date 2026-05-29 --out ./daily.md +weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json ``` The command parses flags, loads configuration, fetches weather data, builds a -Daily briefing, and writes the JSON artifact to `--out`. +Daily briefing, writes the `data_package` JSON artifact to `--out`, and invokes +`scriptorium render --input data_package= --format json`. ## Command Overview @@ -25,9 +27,11 @@ weatherreporter run morning weatherreporter run evening ``` -`generate daily` writes a briefing JSON artifact. Other `generate` commands -resolve one report request and stop before report generation. `run` commands -resolve a scheduled batch request and stop before execution. +`generate daily` writes a data package JSON artifact, writes the Daily briefing +snapshot under the configured workspace, and writes the render preflight output +under the configured workspace. Other `generate` commands resolve one report +request and stop before report generation. `run` commands resolve a scheduled +batch request and stop before execution. ## Flags @@ -35,7 +39,7 @@ resolve a scheduled batch request and stop before execution. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--units VALUE`: override configured Weather API units. - `--tz NAME`: override configured Weather API timezone. -- `--out PATH`: output path for `generate daily`; reserved for later generated report output on other `generate` commands. +- `--out PATH`: data package output path for `generate daily`; reserved for later generated report output on other `generate` commands. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md new file mode 100644 index 0000000..c05b99e --- /dev/null +++ b/docs/internal/prompt-input.md @@ -0,0 +1,52 @@ +# Prompt Input Internals + +This document describes the implemented prompt input package boundary. + +## Purpose + +`internal/promptinput` converts a structured briefing package into the +`data_package` JSON file passed to `scriptorium` prompts. + +## Inputs and Outputs + +Input: + +- `briefing.Package` + +Output: + +- `promptinput.Package` JSON with report metadata, briefing content, source + warnings, RunID, and an empty Recent Changes section. + +## Boundaries + +- This package owns the prompt input schema and required-field validation. +- It does not fetch weather data, compute forecast summaries, compare prior + snapshots, or invoke `scriptorium`. + +## Behavior + +- `promptinput.Build` copies report metadata from the briefing package. +- `promptinput.Validate` rejects missing or inconsistent required fields before + render preflight. +- `promptinput.Save` writes JSON atomically where practical. +- Recent Changes is present as an empty `items` list until structured comparison + is implemented. + +## Failure Behavior + +Validation errors name the missing or inconsistent field. Save failures include +the filesystem operation and path context. + +## Tests + +Inspect: + +- `internal/promptinput/package_test.go` +- `internal/app/app_test.go` + +## Invariants + +- Prompt input data remains structured JSON. +- Briefing metadata and top-level report metadata must agree. +- Recent Changes is not inferred from rendered report text. diff --git a/docs/internal/scriptorium-adapter.md b/docs/internal/scriptorium-adapter.md new file mode 100644 index 0000000..eb0137d --- /dev/null +++ b/docs/internal/scriptorium-adapter.md @@ -0,0 +1,63 @@ +# Scriptorium Adapter Internals + +This document describes the implemented `scriptorium` subprocess adapter. + +## Purpose + +`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt +wiring without LLM generation. + +## Inputs and Outputs + +Input: + +- prompt ID +- prompt input data package path +- configured binary, config path, profile, timeout, and extra arguments + +Output: + +- captured stdout +- captured stderr +- exit code +- full argv used for inspection + +## Boundaries + +- This adapter owns `scriptorium` CLI flag construction and subprocess + execution. +- It does not choose report types, build prompt input, fetch weather data, or + decide workflow order. + +## Behavior + +The render invocation shape is: + +```text +scriptorium render --prompt --input data_package= --format json +``` + +Configured `--config` and `--profile` values are added when present. Arguments +are passed directly as argv, not through a shell. Stdout and stderr are captured +separately. `SaveRenderResult` writes the captured result as JSON for inspection. + +## Failure Behavior + +Nonzero render exits return both the captured result and an error containing +the exit code and stderr. Command execution respects context cancellation and +the configured timeout. + +## Tests + +Inspect: + +- `internal/adapters/scriptorium/runner_test.go` +- `internal/app/app_test.go` +- `internal/cli/root_test.go` + +## Invariants + +- `scriptorium` details stay inside the adapter package. +- The input name for prompt packages is always `data_package`. +- Render preflight is orchestration behavior; final report generation is not + implemented in this adapter yet. diff --git a/internal/adapters/scriptorium/runner.go b/internal/adapters/scriptorium/runner.go new file mode 100644 index 0000000..f9a7b56 --- /dev/null +++ b/internal/adapters/scriptorium/runner.go @@ -0,0 +1,154 @@ +// Package scriptorium adapts the external scriptorium CLI. +package scriptorium + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +type CommandRunner interface { + Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) +} + +type CommandResult struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { + runCtx := ctx + cancel := func() {} + if timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, timeout) + } + defer cancel() + + cmd := exec.CommandContext(runCtx, name, args...) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), ExitCode: 0} + if err == nil { + return result, nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + if runCtx.Err() != nil { + return result, runCtx.Err() + } + return result, err +} + +type Runner struct { + Binary string + ConfigPath string + Profile string + Timeout time.Duration + ExtraArgs []string + Commands CommandRunner +} + +type RenderRequest struct { + PromptID string + DataPackagePath string +} + +type RenderResult struct { + Command []string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exitCode"` +} + +func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) { + if req.PromptID == "" { + return nil, fmt.Errorf("prompt id is required") + } + if req.DataPackagePath == "" { + return nil, fmt.Errorf("data package path is required") + } + binary := r.Binary + if binary == "" { + binary = "scriptorium" + } + commands := r.Commands + if commands == nil { + commands = ExecRunner{} + } + args := r.renderArgs(req) + commandResult, err := commands.Run(ctx, binary, args, r.Timeout) + if err != nil { + return nil, fmt.Errorf("run scriptorium render: %w", err) + } + result := &RenderResult{ + Command: append([]string{binary}, args...), + Stdout: string(commandResult.Stdout), + Stderr: string(commandResult.Stderr), + ExitCode: commandResult.ExitCode, + } + if commandResult.ExitCode != 0 { + return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr) + } + return result, nil +} + +func (r Runner) renderArgs(req RenderRequest) []string { + args := []string{"render"} + if r.ConfigPath != "" { + args = append(args, "--config", r.ConfigPath) + } + if r.Profile != "" { + args = append(args, "--profile", r.Profile) + } + args = append(args, + "--prompt", req.PromptID, + "--input", "data_package="+req.DataPackagePath, + "--format", "json", + ) + args = append(args, r.ExtraArgs...) + return args +} + +func SaveRenderResult(path string, result *RenderResult) error { + if result == nil { + return fmt.Errorf("render result is required") + } + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshal render result: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temporary preflight file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temporary preflight file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary preflight file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("save preflight %q: %w", path, err) + } + return nil +} diff --git a/internal/adapters/scriptorium/runner_test.go b/internal/adapters/scriptorium/runner_test.go new file mode 100644 index 0000000..f31ed6f --- /dev/null +++ b/internal/adapters/scriptorium/runner_test.go @@ -0,0 +1,89 @@ +package scriptorium + +import ( + "context" + "reflect" + "strings" + "testing" + "time" +) + +func TestRenderConstructsCommand(t *testing.T) { + commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}} + runner := Runner{ + Binary: "/usr/local/bin/scriptorium", + ConfigPath: "/etc/scriptorium.yml", + Profile: "weather", + Timeout: time.Minute, + Commands: commands, + } + + result, err := runner.Render(context.Background(), RenderRequest{ + PromptID: "weather.daily_report", + DataPackagePath: "/tmp/data_package.json", + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + wantArgs := []string{ + "render", + "--config", "/etc/scriptorium.yml", + "--profile", "weather", + "--prompt", "weather.daily_report", + "--input", "data_package=/tmp/data_package.json", + "--format", "json", + } + if commands.name != "/usr/local/bin/scriptorium" { + t.Fatalf("command name = %q, want custom binary", commands.name) + } + if !reflect.DeepEqual(commands.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", commands.args, wantArgs) + } + if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) { + t.Fatalf("result command = %#v, want full argv", result.Command) + } +} + +func TestRenderReturnsResultForNonzeroExit(t *testing.T) { + runner := Runner{ + Commands: &fakeCommands{ + result: CommandResult{ + Stderr: []byte("missing input"), + ExitCode: 1, + }, + }, + } + + result, err := runner.Render(context.Background(), RenderRequest{ + PromptID: "weather.daily_report", + DataPackagePath: "/tmp/data_package.json", + }) + if err == nil { + t.Fatal("Render() error = nil, want nonzero exit error") + } + if result == nil { + t.Fatal("Render() result = nil, want captured result") + } + if result.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1", result.ExitCode) + } + if !strings.Contains(err.Error(), "missing input") { + t.Fatalf("error = %q, want stderr context", err.Error()) + } +} + +type fakeCommands struct { + name string + args []string + timeout time.Duration + result CommandResult + err error +} + +func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { + f.name = name + f.args = append([]string{}, args...) + f.timeout = timeout + return f.result, f.err +} diff --git a/internal/app/app.go b/internal/app/app.go index acd4339..b6c06c4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,10 +7,12 @@ import ( "path/filepath" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) @@ -57,21 +59,41 @@ type DailyBriefingRequest struct { OutputPath string } +type DailyPreparationRequest struct { + Config config.Config + Resolved report.Resolved + DataPackagePath string + Renderer Renderer +} + type DailyBriefingResult struct { Package briefing.Package OutputPath string } +type DailyPreparationResult struct { + Briefing briefing.Package + BriefingPath string + DataPackage promptinput.Package + DataPackagePath string + PreflightPath string + RenderResult *scriptorium.RenderResult +} + +type Renderer interface { + Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) +} + func Generate(ctx context.Context, req GenerateRequest) error { resolved, err := ResolveGenerate(req, time.Now()) if err != nil { return err } if resolved.Definition.ID == report.DailyToday { - _, err := GenerateDailyBriefing(ctx, DailyBriefingRequest{ - Config: req.Config, - Resolved: resolved, - OutputPath: req.OutputPath, + _, err := PrepareDailyReport(ctx, DailyPreparationRequest{ + Config: req.Config, + Resolved: resolved, + DataPackagePath: req.OutputPath, }) return err } @@ -192,6 +214,71 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil } +func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*DailyPreparationResult, error) { + bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) + if err != nil { + return nil, err + } + briefingPackage, err := BuildDailyBriefing(DailyBriefingRequest{ + Config: req.Config, + Resolved: req.Resolved, + }, bundle) + if err != nil { + return nil, err + } + + briefingPath := defaultBriefingPath(req.Config, req.Resolved) + if err := briefing.Save(briefingPath, briefingPackage); err != nil { + return nil, err + } + + dataPackage, err := promptinput.Build(briefingPackage) + if err != nil { + return nil, err + } + dataPackagePath := req.DataPackagePath + if dataPackagePath == "" { + dataPackagePath = defaultDataPackagePath(req.Config, req.Resolved) + } + if err := promptinput.Save(dataPackagePath, dataPackage); err != nil { + return nil, err + } + + renderer := req.Renderer + if renderer == nil { + renderer = scriptorium.Runner{ + Binary: req.Config.Scriptorium.Binary, + ConfigPath: req.Config.Scriptorium.ConfigPath, + Profile: req.Config.Scriptorium.Profile, + Timeout: req.Config.Scriptorium.Timeout, + ExtraArgs: req.Config.Scriptorium.ExtraArgs, + } + } + renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{ + PromptID: req.Resolved.Definition.PromptID, + DataPackagePath: dataPackagePath, + }) + + preflightPath := defaultPreflightPath(req.Config, req.Resolved) + if renderResult != nil { + if err := scriptorium.SaveRenderResult(preflightPath, renderResult); err != nil { + return nil, err + } + } + if renderErr != nil { + return nil, renderErr + } + + return &DailyPreparationResult{ + Briefing: briefingPackage, + BriefingPath: briefingPath, + DataPackage: dataPackage, + DataPackagePath: dataPackagePath, + PreflightPath: preflightPath, + RenderResult: renderResult, + }, nil +} + func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { @@ -222,3 +309,15 @@ func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string { filename := validDate + "." + string(resolved.Definition.ID) + ".briefing.json" return filepath.Join(cfg.Workspace.Root, cfg.Workspace.SnapshotsDir, "daily", validDate, filename) } + +func defaultDataPackagePath(cfg config.Config, resolved report.Resolved) string { + validDate := resolved.ValidPeriod.Start.Format("2006-01-02") + filename := resolved.Metadata().RunID + ".data_package.json" + return filepath.Join(cfg.Workspace.Root, cfg.Workspace.DataPackagesDir, "daily", validDate, filename) +} + +func defaultPreflightPath(cfg config.Config, resolved report.Resolved) string { + validDate := resolved.ValidPeriod.Start.Format("2006-01-02") + filename := resolved.Metadata().RunID + ".render.json" + return filepath.Join(cfg.Workspace.Root, cfg.Workspace.PreflightDir, "daily", validDate, filename) +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index eaa7739..7bb114a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "net/http" "net/http/httptest" "os" @@ -10,6 +11,7 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) @@ -130,6 +132,110 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) { } } +func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + renderer := &recordingRenderer{ + result: &scriptorium.RenderResult{ + Command: []string{"scriptorium", "render"}, + Stdout: `{"prepared":true}`, + ExitCode: 0, + }, + } + dataPackagePath := filepath.Join(t.TempDir(), "daily.data_package.json") + + result, err := PrepareDailyReport(context.Background(), DailyPreparationRequest{ + Config: cfg, + Resolved: resolved, + DataPackagePath: dataPackagePath, + Renderer: renderer, + }) + if err != nil { + t.Fatalf("PrepareDailyReport() error = %v", err) + } + + if renderer.calls != 1 { + t.Fatalf("renderer calls = %d, want 1", renderer.calls) + } + if renderer.request.PromptID != "weather.daily_report" { + t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID) + } + if renderer.request.DataPackagePath != dataPackagePath { + t.Fatalf("DataPackagePath = %q, want %q", renderer.request.DataPackagePath, dataPackagePath) + } + for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected artifact %q: %v", path, err) + } + } + data, err := os.ReadFile(dataPackagePath) + if err != nil { + t.Fatalf("read data package: %v", err) + } + if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { + t.Fatalf("data package missing expected content:\n%s", string(data)) + } + preflight, err := os.ReadFile(result.PreflightPath) + if err != nil { + t.Fatalf("read preflight: %v", err) + } + if !strings.Contains(string(preflight), `prepared`) { + t.Fatalf("preflight output missing render stdout:\n%s", string(preflight)) + } +} + +func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + renderer := &recordingRenderer{ + result: &scriptorium.RenderResult{ + Command: []string{"scriptorium", "render"}, + Stderr: "render failed", + ExitCode: 1, + }, + err: errors.New("scriptorium render exited with code 1: render failed"), + } + + _, err = PrepareDailyReport(context.Background(), DailyPreparationRequest{ + Config: cfg, + Resolved: resolved, + Renderer: renderer, + }) + if err == nil { + t.Fatal("PrepareDailyReport() error = nil, want render error") + } + preflightPath := defaultPreflightPath(cfg, resolved) + preflight, readErr := os.ReadFile(preflightPath) + if readErr != nil { + t.Fatalf("read failed preflight: %v", readErr) + } + if !strings.Contains(string(preflight), `"exitCode": 1`) { + t.Fatalf("failed preflight was not persisted:\n%s", string(preflight)) + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -227,3 +333,16 @@ func mustParse(value string) time.Time { } return parsed } + +type recordingRenderer struct { + calls int + request scriptorium.RenderRequest + result *scriptorium.RenderResult + err error +} + +func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { + r.calls++ + r.request = req + return r.result, r.err +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 49d6c96..ea3503f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ Options: --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. - --out PATH Override report output path or directory. + --out PATH Override the generated data package path for generate daily. ` type Runner struct { @@ -210,7 +210,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") if includeOutput { - fs.StringVar(&opts.Output, "out", "", "report output path or directory") + fs.StringVar(&opts.Output, "out", "", "generated data package path") } } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 08750e4..061cd1a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -71,13 +71,17 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { } } -func TestRunGenerateDailyWritesBriefing(t *testing.T) { +func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) { server := dailyServer(t) - configPath := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: "+server.URL+"/\n timezone: America/Chicago\n"), 0o600); err != nil { + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } - outPath := filepath.Join(t.TempDir(), "daily.briefing.json") + outPath := filepath.Join(tempDir, "daily.data_package.json") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} @@ -93,10 +97,24 @@ func TestRunGenerateDailyWritesBriefing(t *testing.T) { } data, err := os.ReadFile(outPath) if err != nil { - t.Fatalf("read briefing: %v", err) + t.Fatalf("read data package: %v", err) } - if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily_today"`) { - t.Fatalf("briefing output missing expected content:\n%s", string(data)) + 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)) + } + preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) + if err != nil { + t.Fatalf("glob preflight: %v", err) + } + if len(preflightMatches) != 1 { + t.Fatalf("preflight files = %#v, want one render output", preflightMatches) + } + preflight, err := os.ReadFile(preflightMatches[0]) + if err != nil { + t.Fatalf("read preflight: %v", err) + } + if !strings.Contains(string(preflight), `ok`) { + t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) } } @@ -247,3 +265,13 @@ func dailyServer(t *testing.T) *httptest.Server { t.Cleanup(server.Close) return server } + +func writeFakeScriptorium(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "scriptorium") + body := "#!/bin/sh\nprintf '{\"ok\":true,\"argv\":\"%s\"}' \"$*\"\n" + if err := os.WriteFile(path, []byte(body), 0o700); err != nil { + t.Fatalf("write fake scriptorium: %v", err) + } + return path +} diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go new file mode 100644 index 0000000..e51f55c --- /dev/null +++ b/internal/promptinput/package.go @@ -0,0 +1,140 @@ +// Package promptinput builds prompt data packages from briefing packages. +package promptinput + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const SchemaVersion = "weatherreporter.data_package.v1" + +type Package struct { + SchemaVersion string `json:"schemaVersion"` + RunID string `json:"runId"` + Report Report `json:"report"` + Briefing briefing.Package `json:"briefing"` + RecentChanges RecentChanges `json:"recentChanges"` + SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` +} + +type Report struct { + ID report.ID `json:"id"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` +} + +type RecentChanges struct { + Items []Change `json:"items"` +} + +type Change struct { + Type string `json:"type"` + Message string `json:"message"` +} + +func Build(briefingPackage briefing.Package) (Package, error) { + pkg := Package{ + SchemaVersion: SchemaVersion, + RunID: briefingPackage.Metadata.RunID, + Report: Report{ + ID: briefingPackage.Metadata.ReportID, + Variant: briefingPackage.Metadata.Variant, + PromptID: briefingPackage.Metadata.PromptID, + GeneratedAt: briefingPackage.Metadata.GeneratedAt, + Timezone: briefingPackage.Metadata.Timezone, + ValidPeriod: briefingPackage.Metadata.ValidPeriod, + }, + Briefing: briefingPackage, + RecentChanges: RecentChanges{Items: []Change{}}, + SourceWarnings: briefingPackage.Metadata.SourceWarnings, + } + if err := Validate(pkg); err != nil { + return Package{}, err + } + return pkg, nil +} + +func Validate(pkg Package) error { + if pkg.SchemaVersion == "" { + return fmt.Errorf("schemaVersion is required") + } + if pkg.RunID == "" { + return fmt.Errorf("runId is required") + } + if pkg.Report.ID == "" { + return fmt.Errorf("report.id is required") + } + if pkg.Report.PromptID == "" { + return fmt.Errorf("report.promptId is required") + } + if pkg.Report.GeneratedAt.IsZero() { + return fmt.Errorf("report.generatedAt is required") + } + if pkg.Report.Timezone == "" { + return fmt.Errorf("report.timezone is required") + } + if !pkg.Report.ValidPeriod.IsValid() { + return fmt.Errorf("report.validPeriod must be valid") + } + if pkg.Briefing.Metadata.RunID == "" { + return fmt.Errorf("briefing.metadata.runId is required") + } + if pkg.Briefing.Metadata.RunID != pkg.RunID { + return fmt.Errorf("briefing.metadata.runId must match runId") + } + if pkg.Briefing.Metadata.SchemaVersion == "" { + return fmt.Errorf("briefing.metadata.schemaVersion is required") + } + 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 { + return fmt.Errorf("briefing.daily is required") + } + return nil +} + +func Save(path string, pkg Package) error { + if err := Validate(pkg); err != nil { + return err + } + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return fmt.Errorf("marshal data package: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temporary data package file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temporary data package file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary data package file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("save data package %q: %w", path, err) + } + return nil +} diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go new file mode 100644 index 0000000..675f641 --- /dev/null +++ b/internal/promptinput/package_test.go @@ -0,0 +1,94 @@ +package promptinput + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestBuildDailyDataPackage(t *testing.T) { + briefingPackage := validBriefingPackage() + + pkg, err := Build(briefingPackage) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if pkg.SchemaVersion != SchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) + } + if pkg.RunID != "20260529T100000Z_daily_today" { + t.Fatalf("RunID = %q, want briefing run id", pkg.RunID) + } + if pkg.Report.PromptID != "weather.daily_report" { + t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) + } + if pkg.Briefing.Daily == nil { + t.Fatal("Briefing.Daily = nil") + } + if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { + t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) + } +} + +func TestValidateRequiresFields(t *testing.T) { + pkg, err := Build(validBriefingPackage()) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + pkg.RunID = "" + + err = Validate(pkg) + if err == nil { + t.Fatal("Validate() error = nil, want required field error") + } + if !strings.Contains(err.Error(), "runId") { + t.Fatalf("error = %q, want runId context", err.Error()) + } +} + +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 { + t.Fatalf("first marshal: %v", err) + } + second, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + t.Fatalf("second marshal: %v", err) + } + if string(first) != string(second) { + t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second)) + } +} + +func validBriefingPackage() briefing.Package { + generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: "20260529T100000Z_daily_today", + ReportID: report.DailyToday, + PromptID: "weather.daily_report", + GeneratedAt: generatedAt, + Units: "us", + 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), + }, + }, + Daily: &briefing.Daily{ + ForecastSummaryDate: "2026-05-29", + }, + } +}