// Package app owns application orchestration and top-level use cases. package app import ( "context" "fmt" "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" ) 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 Date time.Time StormStart time.Time StormEnd time.Time } type BatchRequest struct { Config config.Config Batch BatchKind } type FetchBundleRequest struct { Config config.Config OutputPath string } type DailyBriefingRequest struct { Config config.Config Resolved report.Resolved 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 := PrepareDailyReport(ctx, DailyPreparationRequest{ Config: req.Config, Resolved: resolved, DataPackagePath: req.OutputPath, }) return err } return fmt.Errorf("generate is not implemented") } func RunBatch(ctx context.Context, req BatchRequest) error { _ = ctx if _, err := ResolveBatch(req, time.Now()); err != nil { return err } return fmt.Errorf("run is not implemented") } 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) { bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) if err != nil { return nil, err } pkg, err := BuildDailyBriefing(req, bundle) if err != nil { return nil, err } outputPath := req.OutputPath if outputPath == "" { outputPath = defaultBriefingPath(req.Config, req.Resolved) } if err := briefing.Save(outputPath, pkg); err != nil { return nil, err } 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 { 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, }) } 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) } func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string { validDate := resolved.ValidPeriod.Start.Format("2006-01-02") 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) }