Write reports to operator-selected outputs

This commit is contained in:
2026-08-01 19:33:12 +00:00
parent ac8d618111
commit 62a12dd661
17 changed files with 393 additions and 94 deletions

View File

@@ -4,7 +4,9 @@ package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
@@ -42,6 +44,7 @@ const (
type GenerateRequest struct {
Config config.Config
Report ReportKind
WorkingDir string
OutputPath string
LLMDebugDir string
Now time.Time
@@ -56,6 +59,7 @@ type BatchRequest struct {
Config config.Config
Batch BatchKind
Now time.Time
WorkingDir string
OutputDir string
LLMDebugDir string
Collector Collector
@@ -258,6 +262,11 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if err != nil {
return nil, err
}
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, resolved)
if err != nil {
return nil, err
}
req.OutputPath = outputPath
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
@@ -302,6 +311,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
outputDir, err := resolveOutputDir(req.WorkingDir, req.OutputDir)
if err != nil {
return nil, err
}
req.OutputDir = outputDir
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
@@ -340,7 +354,10 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
for _, planned := range plannedReports {
resolved := planned.Resolved
item := batchReportResult(planned)
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
outputPath, err := plannedBatchOutputPath(req.OutputDir, planned)
if err != nil {
return nil, err
}
reportResult, err := generatePromptReport(ctx, promptReportRequest{
GenerateRequest: GenerateRequest{
Config: req.Config,
@@ -440,18 +457,91 @@ func batchReportResult(planned plannedBatchReport) BatchReportResult {
}
}
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
if outputDir == "" {
return ""
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
outputName, err := planned.Resolved.OutputName()
if err != nil {
return "", err
}
outputCopyName := planned.OutputCopyName
if outputCopyName == "" {
outputCopyName = planned.Resolved.Definition.BatchOutputName
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func resolveReportOutputPath(workingDir, override string, resolved report.Resolved) (string, error) {
outputName, err := resolved.OutputName()
if err != nil {
return "", err
}
if outputCopyName == "" {
return ""
return resolveOutputPath(workingDir, override, outputName)
}
func resolveOutputDir(workingDir, override string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
return filepath.Join(outputDir, outputCopyName)
if override == "" {
return workingDir, nil
}
if strings.TrimSpace(override) == "" {
return "", fmt.Errorf("output directory is required")
}
directory := override
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
directory = filepath.Clean(directory)
if info, err := os.Stat(directory); err == nil && !info.IsDir() {
return "", fmt.Errorf("output directory %q is not a directory", directory)
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect output directory %q: %w", directory, err)
}
return directory, nil
}
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
path := override
if path == "" {
path = defaultName
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
if !filepath.IsAbs(path) {
path = filepath.Join(workingDir, path)
}
return validateOutputPath(path)
}
func validateWorkingDir(workingDir string) (string, error) {
if strings.TrimSpace(workingDir) == "" {
return "", fmt.Errorf("working directory is required")
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func validateOutputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("final output path %q must be absolute", path)
}
if filepath.Dir(path) == path {
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
return "", fmt.Errorf("final output path %q is a directory", path)
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
}
return path, nil
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -590,7 +680,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
return result, nil
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
result.NotificationPath = notificationPath
result.Notification = notification
@@ -636,7 +726,7 @@ func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolv
if err != nil {
return result, notificationPath, &NotificationError{
Request: notificationRequest,
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
}
}
return result, notificationPath, nil
@@ -655,7 +745,7 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
}
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, filepath.Base(reportPath))
if err != nil {
return NotificationRequest{}, err
}
@@ -688,16 +778,20 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
}, nil
}
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, outputName string) (config.DistributorTemplateValues, error) {
values := config.DistributorTemplateValues{
LocationID: cfg.Location.ID,
ReportID: string(resolved.Definition.ID),
RunID: runID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: batchOutputName,
BatchOutputName: outputName,
}
if values.BatchOutputName == "" {
values.BatchOutputName = resolved.Definition.BatchOutputName
var err error
values.BatchOutputName, err = resolved.OutputName()
if err != nil {
return config.DistributorTemplateValues{}, err
}
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err