// Package app owns application orchestration and top-level use cases. package app import ( "context" "fmt" "os" "path/filepath" "strings" "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/changes" "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/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type ReportKind string const ( ReportDaily ReportKind = "daily" ReportTomorrow ReportKind = "tomorrow" ReportThreeDay ReportKind = "three-day" ReportWeekend ReportKind = "weekend" ReportStorm ReportKind = "storm" ) type BatchKind string const ( BatchMorning BatchKind = "morning" BatchEvening BatchKind = "evening" ) type GenerateRequest struct { Config config.Config Report ReportKind OutputPath string Now time.Time Date time.Time StormStart time.Time StormEnd time.Time } type BatchRequest struct { Config config.Config Batch BatchKind Now time.Time OutputDir string Renderer Renderer Store state.Store } type FetchBundleRequest struct { Config config.Config OutputPath string } type BriefingRequest struct { Config config.Config Resolved report.Resolved OutputPath string } type DailyBriefingRequest = BriefingRequest type ReportRequest struct { Config config.Config Resolved report.Resolved OutputPath string Renderer Renderer Store state.Store } type DailyReportRequest = ReportRequest type BriefingResult struct { Package briefing.Package OutputPath string } type DailyBriefingResult = BriefingResult type ReportResult 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 RecentChanges []changes.Change RenderResult *scriptorium.RenderResult RunResult *scriptorium.RunResult } type DailyReportResult = ReportResult type BatchResult struct { Batch BatchKind `json:"batch"` StartedAt time.Time `json:"startedAt"` FinishedAt time.Time `json:"finishedAt"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Reports []BatchReportResult `json:"reports"` } type BatchReportResult struct { ReportID report.ID `json:"reportId"` ReportName string `json:"reportName"` PromptID string `json:"promptId"` RunID string `json:"runId"` Status string `json:"status"` Error string `json:"error,omitempty"` GeneratedAt time.Time `json:"generatedAt"` ValidPeriod timeutil.Period `json:"validPeriod"` BriefingPath string `json:"briefingPath,omitempty"` DataPackagePath string `json:"dataPackagePath,omitempty"` PreflightPath string `json:"preflightPath,omitempty"` ReportPath string `json:"reportPath,omitempty"` OutputPath string `json:"outputPath,omitempty"` MetadataPath string `json:"metadataPath,omitempty"` } type BatchError struct { Result *BatchResult } func (e BatchError) Error() string { if e.Result == nil { return "batch failed" } return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total) } 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 { now := req.Now if now.IsZero() { now = time.Now() } resolved, err := ResolveGenerate(req, now) if err != nil { return err } if isGeneratedReport(resolved.Definition.ID) { _, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, OutputPath: req.OutputPath, }) return err } return fmt.Errorf("generate is not implemented") } func RunBatch(ctx context.Context, req BatchRequest) error { result, err := RunBatchDetailed(ctx, req) if err != nil { return err } if result.Failed > 0 { return BatchError{Result: result} } return nil } func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, error) { now := req.Now if now.IsZero() { now = time.Now() } resolvedReports, err := ResolveBatch(req, now) if err != nil { return nil, err } if req.Batch == BatchEvening || req.Batch == BatchMorning { store := req.Store if store == nil { defaultStore, err := defaultStore(req.Config) if err != nil { return nil, err } store = defaultStore } startedAt := now result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} for _, resolved := range resolvedReports { if !isGeneratedReport(resolved.Definition.ID) { return nil, fmt.Errorf("run is not implemented") } } for _, resolved := range resolvedReports { item := batchReportResult(resolved) if paths, err := store.Paths(resolved); err == nil { item.BriefingPath = paths.Briefing item.DataPackagePath = paths.DataPackage item.PreflightPath = paths.Preflight item.ReportPath = paths.RenderedReport item.MetadataPath = paths.Metadata } outputPath := batchOutputPath(req.OutputDir, resolved.Definition) reportResult, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, OutputPath: outputPath, Renderer: req.Renderer, Store: store, }) if err != nil { item.Status = "failed" item.Error = err.Error() result.Failed++ } else { item.Status = "succeeded" item.BriefingPath = reportResult.BriefingPath item.DataPackagePath = reportResult.DataPackagePath item.PreflightPath = reportResult.PreflightPath item.ReportPath = reportResult.ReportPath item.OutputPath = reportResult.OutputPath item.MetadataPath = reportResult.MetadataPath result.Succeeded++ } result.Reports = append(result.Reports, item) } result.Total = len(result.Reports) result.FinishedAt = time.Now() return result, nil } return nil, fmt.Errorf("run is not implemented") } func batchReportResult(resolved report.Resolved) BatchReportResult { metadata := resolved.Metadata() return BatchReportResult{ ReportID: resolved.Definition.ID, ReportName: resolved.Definition.Name, PromptID: resolved.Definition.PromptID, RunID: metadata.RunID, GeneratedAt: metadata.GeneratedAt, ValidPeriod: metadata.ValidPeriod, } } func batchOutputPath(outputDir string, definition report.Definition) string { if outputDir == "" || definition.DefaultOutputName == "" { return "" } name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-") return filepath.Join(outputDir, name) } func isGeneratedReport(id report.ID) bool { return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm } func isDailyReport(id report.ID) bool { return id == report.DailyToday || id == report.DailyTomorrow } func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { return report.Resolved{}, err } id, err := reportIDForCommand(req.Report) if err != nil { return report.Resolved{}, err } return report.DefaultRegistry().Resolve(id, report.ResolveRequest{ Now: now, Location: location, Date: req.Date, StormStart: req.StormStart, StormEnd: req.StormEnd, }) } func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { return nil, err } batch, err := reportBatchForCommand(req.Batch) if err != nil { return nil, err } return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{ Now: now, Location: location, }) } func reportIDForCommand(kind ReportKind) (report.ID, error) { switch kind { case ReportDaily: return report.DailyToday, nil case ReportTomorrow: return report.DailyTomorrow, nil case ReportThreeDay: return report.ThreeDay, nil case ReportWeekend: return report.Weekend, nil case ReportStorm: return report.Storm, nil default: return "", fmt.Errorf("unknown report command %q", kind) } } func reportBatchForCommand(kind BatchKind) (report.Batch, error) { switch kind { case BatchMorning: return report.Morning, nil case BatchEvening: return report.Evening, nil default: return "", fmt.Errorf("unknown batch command %q", kind) } } func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { client, err := weatherapi.New(req.Config) if err != nil { return nil, err } bundle, err := client.FetchBundle(ctx) if err != nil { return nil, err } return bundle, nil } func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { if req.OutputPath == "" { return nil, fmt.Errorf("output path is required") } bundle, err := FetchBundle(ctx, req) if err != nil { return nil, err } if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { return nil, err } return bundle, nil } func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) { return GenerateBriefing(ctx, req) } func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) { bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) if err != nil { return nil, err } pkg, err := BuildBriefing(req, bundle) if err != nil { return nil, err } outputPath := req.OutputPath if outputPath == "" { store, err := defaultStore(req.Config) if err != nil { return nil, err } paths, err := store.Paths(req.Resolved) if err != nil { return nil, err } outputPath = paths.Briefing } if err := briefing.Save(outputPath, pkg); err != nil { return nil, err } return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil } func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) { return GenerateReport(ctx, req) } func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { store := req.Store if store == nil { defaultStore, err := defaultStore(req.Config) if err != nil { return nil, err } store = defaultStore } paths, err := store.Paths(req.Resolved) if err != nil { return nil, err } priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved) if err != nil { return nil, err } bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) if err != nil { return nil, err } briefingPackage, err := BuildBriefing(BriefingRequest{ Config: req.Config, Resolved: req.Resolved, }, bundle) if err != nil { return nil, err } briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage) if err != nil { return nil, err } recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) if err != nil { return nil, err } dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges) if err != nil { return nil, err } dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage) if 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 := paths.Preflight if renderResult != nil { var err error preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult) if err != nil { return nil, err } } metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{ Briefing: briefingPath, Metadata: paths.Metadata, DataPackage: dataPackagePath, Preflight: preflightPath, RenderedReport: paths.RenderedReport, }) metadataPath, metadataErr := store.SaveMetadata(ctx, metadata) if metadataErr != nil { return nil, metadataErr } if renderErr != nil { return nil, renderErr } 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 &ReportResult{ Briefing: briefingPackage, BriefingPath: briefingPath, DataPackage: dataPackage, DataPackagePath: dataPackagePath, PreflightPath: preflightPath, ReportPath: reportPath, OutputPath: outputPath, Metadata: metadata, MetadataPath: metadataPath, PriorSnapshot: priorSnapshot, RecentChanges: recentChanges, RenderResult: renderResult, RunResult: runResult, }, nil } func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { return BuildBriefing(req, bundle) } func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { return briefing.Package{}, err } dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts)) for _, daypart := range req.Config.Dayparts { dayparts = append(dayparts, forecast.DaypartDefinition{ Name: daypart.Name, Start: daypart.Start, End: daypart.End, }) } switch req.Resolved.Definition.ID { case report.DailyToday, report.DailyTomorrow: summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) if err != nil { return briefing.Package{}, err } return briefing.BuildDaily(briefing.BuildContext{ Resolved: req.Resolved, Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, }, summary) case report.ThreeDay, report.Weekend: summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) if err != nil { return briefing.Package{}, err } if req.Resolved.Definition.ID == report.Weekend { return briefing.BuildWeekend(briefing.BuildContext{ Resolved: req.Resolved, Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, }, summaries) } return briefing.BuildThreeDay(briefing.BuildContext{ Resolved: req.Resolved, Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, }, summaries) case report.Storm: return briefing.BuildStorm(briefing.BuildContext{ Resolved: req.Resolved, Bundle: bundle, Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, }) default: return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID) } } func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { return state.NewFilesystemStore(cfg.Workspace) } func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { return recentChanges(ctx, store, priorSnapshot, current, cfg) } func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { if priorSnapshot == nil { return nil, nil } previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath) if err != nil { return nil, err } thresholds := changes.Thresholds{ TemperatureDegrees: cfg.TemperatureDegrees, PrecipProbabilityPoints: cfg.PrecipProbabilityPoints, WindGustMilesPerHour: cfg.WindGustMilesPerHour, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, } switch current.Metadata.ReportID { case report.DailyToday, report.DailyTomorrow: return changes.CompareDaily(previous, current, thresholds) case report.ThreeDay: return changes.CompareThreeDay(previous, current, thresholds) case report.Weekend: return changes.CompareWeekend(previous, current, thresholds) default: return nil, nil } } 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 }