Add Daily prompt input preflight

This commit is contained in:
2026-05-29 17:32:57 +00:00
parent cf8e1de3ff
commit e556ca5edc
12 changed files with 868 additions and 26 deletions

View File

@@ -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)
}

View File

@@ -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
}