Generate Daily reports through scriptorium
This commit is contained in:
@@ -4,6 +4,8 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
@@ -59,12 +61,12 @@ type DailyBriefingRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyPreparationRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
DataPackagePath string
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
type DailyReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
}
|
||||
|
||||
type DailyBriefingResult struct {
|
||||
@@ -72,20 +74,24 @@ type DailyBriefingResult struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyPreparationResult struct {
|
||||
type DailyReportResult struct {
|
||||
Briefing briefing.Package
|
||||
BriefingPath string
|
||||
DataPackage promptinput.Package
|
||||
DataPackagePath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
Metadata state.Metadata
|
||||
MetadataPath string
|
||||
PriorSnapshot *state.PriorSnapshot
|
||||
RenderResult *scriptorium.RenderResult
|
||||
RunResult *scriptorium.RunResult
|
||||
}
|
||||
|
||||
type Renderer interface {
|
||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||
}
|
||||
|
||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
@@ -94,10 +100,10 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
return err
|
||||
}
|
||||
if resolved.Definition.ID == report.DailyToday {
|
||||
_, err := PrepareDailyReport(ctx, DailyPreparationRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
DataPackagePath: req.OutputPath,
|
||||
_, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -226,7 +232,7 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
|
||||
return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
}
|
||||
|
||||
func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*DailyPreparationResult, error) {
|
||||
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
defaultStore, err := defaultStore(req.Config)
|
||||
@@ -269,11 +275,6 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.DataPackagePath != "" && req.DataPackagePath != dataPackagePath {
|
||||
if err := promptinput.Save(req.DataPackagePath, dataPackage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
renderer := req.Renderer
|
||||
if renderer == nil {
|
||||
@@ -313,16 +314,46 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
|
||||
return nil, renderErr
|
||||
}
|
||||
|
||||
return &DailyPreparationResult{
|
||||
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
|
||||
PromptID: req.Resolved.Definition.PromptID,
|
||||
DataPackagePath: dataPackagePath,
|
||||
OutputPath: reportPath,
|
||||
})
|
||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
outputPath := reportPath
|
||||
if req.OutputPath != "" {
|
||||
outputPath = req.OutputPath
|
||||
}
|
||||
metadata.RenderedReportPath = reportPath
|
||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
||||
if metadataErr != nil {
|
||||
return nil, metadataErr
|
||||
}
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
return &DailyReportResult{
|
||||
Briefing: briefingPackage,
|
||||
BriefingPath: briefingPath,
|
||||
DataPackage: dataPackage,
|
||||
DataPackagePath: dataPackagePath,
|
||||
PreflightPath: preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: outputPath,
|
||||
Metadata: metadata,
|
||||
MetadataPath: metadataPath,
|
||||
PriorSnapshot: priorSnapshot,
|
||||
RenderResult: renderResult,
|
||||
RunResult: runResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,3 +385,30 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie
|
||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
return state.NewFilesystemStore(cfg.Workspace)
|
||||
}
|
||||
|
||||
func copyFileAtomic(source string, target string) error {
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read rendered report %q: %w", source, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary report output file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary report output file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary report output file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return fmt.Errorf("save report output %q: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
||||
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -148,42 +148,58 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
renderer := &recordingRenderer{
|
||||
result: &scriptorium.RenderResult{
|
||||
renderResult: &scriptorium.RenderResult{
|
||||
Command: []string{"scriptorium", "render"},
|
||||
Stdout: `{"prepared":true}`,
|
||||
ExitCode: 0,
|
||||
},
|
||||
runResult: &scriptorium.RunResult{
|
||||
Command: []string{"scriptorium", "run"},
|
||||
Stderr: "wrote report",
|
||||
ExitCode: 0,
|
||||
OutputPath: "",
|
||||
},
|
||||
runBody: "# Daily Report\n\nRain this morning.\n",
|
||||
}
|
||||
dataPackagePath := filepath.Join(t.TempDir(), "daily.data_package.json")
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
|
||||
result, err := PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
DataPackagePath: dataPackagePath,
|
||||
Renderer: renderer,
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
}
|
||||
|
||||
if renderer.calls != 1 {
|
||||
t.Fatalf("renderer calls = %d, want 1", renderer.calls)
|
||||
if renderer.renderCalls != 1 {
|
||||
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
|
||||
}
|
||||
if renderer.request.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID)
|
||||
if renderer.runCalls != 1 {
|
||||
t.Fatalf("run calls = %d, want 1", renderer.runCalls)
|
||||
}
|
||||
if renderer.request.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("DataPackagePath = %q, want managed path %q", renderer.request.DataPackagePath, result.DataPackagePath)
|
||||
if renderer.renderRequest.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID)
|
||||
}
|
||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.MetadataPath} {
|
||||
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
||||
}
|
||||
if renderer.runRequest.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath)
|
||||
}
|
||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||
}
|
||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(dataPackagePath); err != nil {
|
||||
t.Fatalf("expected requested data package copy %q: %v", dataPackagePath, err)
|
||||
if _, err := os.Stat(outputPath); err != nil {
|
||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
@@ -203,9 +219,19 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
||||
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
||||
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
||||
}
|
||||
if result.Metadata.RenderedReportPath != result.ReportPath {
|
||||
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
|
||||
}
|
||||
report, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -220,7 +246,7 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
renderer := &recordingRenderer{
|
||||
result: &scriptorium.RenderResult{
|
||||
renderResult: &scriptorium.RenderResult{
|
||||
Command: []string{"scriptorium", "render"},
|
||||
Stderr: "render failed",
|
||||
ExitCode: 1,
|
||||
@@ -228,13 +254,13 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||
}
|
||||
|
||||
_, err = PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PrepareDailyReport() error = nil, want render error")
|
||||
t.Fatal("GenerateDailyReport() error = nil, want render error")
|
||||
}
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
@@ -255,6 +281,60 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
|
||||
}
|
||||
if renderer.runCalls != 0 {
|
||||
t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(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{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
runResult: &scriptorium.RunResult{
|
||||
Stderr: "validation failed",
|
||||
ExitCode: 2,
|
||||
},
|
||||
runErr: errors.New("scriptorium run exited with code 2: validation failed"),
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateDailyReport() error = nil, want run error")
|
||||
}
|
||||
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||
}
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
|
||||
}
|
||||
if _, err := os.Stat(paths.RenderedReport); err != nil {
|
||||
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||
@@ -356,14 +436,33 @@ func mustParse(value string) time.Time {
|
||||
}
|
||||
|
||||
type recordingRenderer struct {
|
||||
calls int
|
||||
request scriptorium.RenderRequest
|
||||
result *scriptorium.RenderResult
|
||||
err error
|
||||
renderCalls int
|
||||
runCalls int
|
||||
renderRequest scriptorium.RenderRequest
|
||||
runRequest scriptorium.RunRequest
|
||||
renderResult *scriptorium.RenderResult
|
||||
runResult *scriptorium.RunResult
|
||||
err error
|
||||
runErr error
|
||||
runBody string
|
||||
}
|
||||
|
||||
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
||||
r.calls++
|
||||
r.request = req
|
||||
return r.result, r.err
|
||||
r.renderCalls++
|
||||
r.renderRequest = req
|
||||
return r.renderResult, r.err
|
||||
}
|
||||
|
||||
func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
|
||||
r.runCalls++
|
||||
r.runRequest = req
|
||||
if r.runBody != "" {
|
||||
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if r.runResult != nil {
|
||||
r.runResult.OutputPath = req.OutputPath
|
||||
}
|
||||
return r.runResult, r.runErr
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user