Add Daily prompt input preflight
This commit is contained in:
@@ -3,14 +3,14 @@
|
|||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data.
|
||||||
|
|
||||||
The application can currently produce a Daily briefing JSON artifact. Rendered
|
The application can currently prepare a Daily prompt input data package and run
|
||||||
reports and `scriptorium` execution are tracked in the roadmap and are not
|
`scriptorium render` as a preflight check. Full rendered reports are tracked in
|
||||||
implemented yet.
|
the roadmap and are not implemented yet.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
22
docs/cli.md
22
docs/cli.md
@@ -1,17 +1,19 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter generate daily` currently writes a Daily briefing JSON artifact.
|
`weatherreporter generate daily` currently writes a Daily prompt input data
|
||||||
Other report generation and scheduled runs still resolve configuration, report
|
package and runs `scriptorium render` as a preflight check. Other report
|
||||||
definitions, and valid periods, then return a not-implemented error.
|
generation and scheduled runs still resolve configuration, report definitions,
|
||||||
|
and valid periods, then return a not-implemented error.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```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
|
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=<path> --format json`.
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
|
||||||
@@ -25,9 +27,11 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate daily` writes a briefing JSON artifact. Other `generate` commands
|
`generate daily` writes a data package JSON artifact, writes the Daily briefing
|
||||||
resolve one report request and stop before report generation. `run` commands
|
snapshot under the configured workspace, and writes the render preflight output
|
||||||
resolve a scheduled batch request and stop before execution.
|
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
|
## 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`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--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.
|
- `--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`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
|
|||||||
52
docs/internal/prompt-input.md
Normal file
52
docs/internal/prompt-input.md
Normal file
@@ -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.
|
||||||
63
docs/internal/scriptorium-adapter.md
Normal file
63
docs/internal/scriptorium-adapter.md
Normal file
@@ -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 <prompt_id> --input data_package=<path> --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.
|
||||||
154
internal/adapters/scriptorium/runner.go
Normal file
154
internal/adapters/scriptorium/runner.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
89
internal/adapters/scriptorium/runner_test.go
Normal file
89
internal/adapters/scriptorium/runner_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"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/promptinput"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -57,21 +59,41 @@ type DailyBriefingRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DailyPreparationRequest struct {
|
||||||
|
Config config.Config
|
||||||
|
Resolved report.Resolved
|
||||||
|
DataPackagePath string
|
||||||
|
Renderer Renderer
|
||||||
|
}
|
||||||
|
|
||||||
type DailyBriefingResult struct {
|
type DailyBriefingResult struct {
|
||||||
Package briefing.Package
|
Package briefing.Package
|
||||||
OutputPath string
|
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 {
|
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||||
resolved, err := ResolveGenerate(req, time.Now())
|
resolved, err := ResolveGenerate(req, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resolved.Definition.ID == report.DailyToday {
|
if resolved.Definition.ID == report.DailyToday {
|
||||||
_, err := GenerateDailyBriefing(ctx, DailyBriefingRequest{
|
_, err := PrepareDailyReport(ctx, DailyPreparationRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: req.OutputPath,
|
DataPackagePath: req.OutputPath,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -192,6 +214,71 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
|
|||||||
return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
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) {
|
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -222,3 +309,15 @@ func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string {
|
|||||||
filename := validDate + "." + string(resolved.Definition.ID) + ".briefing.json"
|
filename := validDate + "." + string(resolved.Definition.ID) + ".briefing.json"
|
||||||
return filepath.Join(cfg.Workspace.Root, cfg.Workspace.SnapshotsDir, "daily", validDate, filename)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"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) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
@@ -227,3 +333,16 @@ func mustParse(value string) time.Time {
|
|||||||
}
|
}
|
||||||
return parsed
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--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 {
|
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.Units, "units", "", "weather API units")
|
||||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
||||||
if includeOutput {
|
if includeOutput {
|
||||||
fs.StringVar(&opts.Output, "out", "", "report output path or directory")
|
fs.StringVar(&opts.Output, "out", "", "generated data package path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,13 +71,17 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateDailyWritesBriefing(t *testing.T) {
|
func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
tempDir := t.TempDir()
|
||||||
if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: "+server.URL+"/\n timezone: America/Chicago\n"), 0o600); err != nil {
|
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)
|
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 stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
@@ -93,10 +97,24 @@ func TestRunGenerateDailyWritesBriefing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
data, err := os.ReadFile(outPath)
|
data, err := os.ReadFile(outPath)
|
||||||
if err != nil {
|
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"`) {
|
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||||
t.Fatalf("briefing output missing expected content:\n%s", string(data))
|
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)
|
t.Cleanup(server.Close)
|
||||||
return server
|
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
|
||||||
|
}
|
||||||
|
|||||||
140
internal/promptinput/package.go
Normal file
140
internal/promptinput/package.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
94
internal/promptinput/package_test.go
Normal file
94
internal/promptinput/package_test.go
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user