Add data-aware batch planning

This commit is contained in:
2026-06-17 15:59:13 +00:00
parent c04e3c5599
commit 6f9255105d
5 changed files with 264 additions and 51 deletions

View File

@@ -265,7 +265,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if now.IsZero() {
now = time.Now()
}
resolvedReports, err := ResolveBatch(req, now)
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
plannedReports, err := planBatchReports(req, now, *collection)
if err != nil {
return nil, err
}
@@ -280,12 +287,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports {
for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, resolved := range resolvedReports {
for _, planned := range plannedReports {
resolved := planned.Resolved
item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage

View File

@@ -2237,7 +2237,7 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
renderer := &selectiveRenderer{
failRenderPrompt: "weather.three_day_outlook",
failRenderPrompt: "weather.tomorrow_generated_text",
runBody: "# Batch Report\n",
}
@@ -2251,23 +2251,23 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed)
}
if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 {
if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
}
var failedThreeDay bool
var failedTomorrow bool
for _, item := range result.Reports {
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedThreeDay = true
if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedTomorrow = true
}
if item.ReportID != report.ThreeDay && item.Status != "succeeded" {
if item.ReportID != report.Tomorrow && item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
}
}
if !failedThreeDay {
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports)
if !failedTomorrow {
t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports)
}
}
@@ -2276,7 +2276,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
cfg := dailyNotificationConfig(t, server)
notifier := &recordingNotifier{
errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"),
report.Tomorrow: errors.New("distributor unavailable"),
},
}
@@ -2291,26 +2291,26 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed)
}
if len(notifier.requests) != 3 {
if len(notifier.requests) != 2 {
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
}
var failedThreeDay bool
var failedTomorrow bool
for _, item := range result.Reports {
if item.ReportID == report.ThreeDay {
if item.ReportID == report.Tomorrow {
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
failedThreeDay = true
failedTomorrow = true
}
if item.NotificationStatus != "failed" {
t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus)
t.Fatalf("Tomorrow notification status = %q, want failed", item.NotificationStatus)
}
if item.NotificationPipelineID != "weatherreporter.three-day" {
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID)
if item.NotificationPipelineID != "weatherreporter.tomorrow" {
t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID)
}
if !strings.Contains(item.NotificationError, "distributor unavailable") {
t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError)
t.Fatalf("Tomorrow notification error = %q, want distributor unavailable", item.NotificationError)
}
continue
}
@@ -2324,8 +2324,8 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
}
}
if !failedThreeDay {
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
if !failedTomorrow {
t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports)
}
cfg.Workspace.Root = t.TempDir()
@@ -2338,7 +2338,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
},
Notifier: &recordingNotifier{
errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"),
report.Tomorrow: errors.New("distributor unavailable"),
},
},
})
@@ -2346,7 +2346,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
if !errors.As(err, &batchErr) {
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err)
}
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 {
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 1 {
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
}
}
@@ -2404,7 +2404,7 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 3 {
if result.Failed != 0 || len(result.Reports) != 2 {
t.Fatalf("summary = %#v, want successful morning batch", result)
}
var todayItem *BatchReportResult
@@ -2448,26 +2448,18 @@ func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
collector := &recordingCollector{err: errors.New("batch collector failed")}
renderer := &recordingRenderer{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
_, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: renderer,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
if err == nil || !strings.Contains(err.Error(), "batch collector failed") {
t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err)
}
if result.Total == 0 || result.Failed != result.Total {
t.Fatalf("batch result = %#v, want every report failed by collector", result)
}
if len(collector.requests) != result.Total {
t.Fatalf("collector requests = %d, want %d", len(collector.requests), result.Total)
}
for _, reportResult := range result.Reports {
if !strings.Contains(reportResult.Error, "batch collector failed") {
t.Fatalf("report error = %q, want fake collector error", reportResult.Error)
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests))
}
if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 {
t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls)

View File

@@ -1,12 +1,85 @@
package app
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type plannedBatchReport struct {
Resolved report.Resolved
OutputCopyName string
}
func planBatchReports(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
resolveReq := report.ResolveRequest{
Now: now,
Location: location,
}
var planned []plannedBatchReport
switch batch {
case report.Morning:
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
if err != nil {
return nil, err
}
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
if err != nil {
return nil, err
}
case report.Evening:
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
var hourly *weatherdata.ForecastRun
if collection.Bundle != nil {
hourly = collection.Bundle.Hourly
}
for _, date := range eligibleDailyDates(hourly, now, location) {
dailyReq := resolveReq
dailyReq.Date = date
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
if err != nil {
return nil, err
}
}
return planned, nil
}
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
resolved, err := registry.Resolve(id, req)
if err != nil {
return nil, err
}
return append(planned, plannedBatchReport{
Resolved: resolved,
OutputCopyName: outputCopyName,
}), nil
}
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
return nil

View File

@@ -5,10 +5,97 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestPlanBatchReportsMorningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchReports() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
}
func TestPlanBatchReportsEveningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchReports() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
}
func TestPlanBatchReportsDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-30", location)
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
if err != nil {
t.Fatalf("planBatchReports() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 2 {
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
}
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
}
func TestPlanBatchReportsMorningExcludesLegacyStaticReports(t *testing.T) {
planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err != nil {
t.Fatalf("planBatchReports() error = %v", err)
}
for _, item := range planned {
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
}
}
}
func TestPlanBatchReportsDynamicDailyOutputCopyNames(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchReports() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 1 {
t.Fatalf("daily reports = %#v, want one Daily report", daily)
}
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
}
if planned[0].OutputCopyName != "" {
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
}
}
func TestPlanBatchReportsRejectsUnknownBatch(t *testing.T) {
_, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
t.Fatalf("planBatchReports() error = %v, want unknown batch command", err)
}
}
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
@@ -142,6 +229,41 @@ func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weather
}
}
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
}
func planningConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
t.Helper()
gotIDs := make([]string, 0, len(got))
for _, item := range got {
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
}
wantIDs := make([]string, 0, len(want))
for _, id := range want {
wantIDs = append(wantIDs, string(id))
}
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
}
}
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
var daily []plannedBatchReport
for _, item := range planned {
if item.Resolved.Definition.ID == report.Daily {
daily = append(daily, item)
}
}
return daily
}
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
t.Helper()
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
@@ -198,6 +320,19 @@ func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, wa
}
}
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if got := period.Start.Format(time.RFC3339); got != wantStart {
t.Fatalf("Start = %s, want %s", got, wantStart)
}
if got := period.End.Format(time.RFC3339); got != wantEnd {
t.Fatalf("End = %s, want %s", got, wantEnd)
}
}
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)

View File

@@ -164,7 +164,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
}
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
@@ -176,7 +176,9 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
}
@@ -191,7 +193,7 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
if !strings.Contains(err.Error(), "1 of 2 reports failed") {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
}
@@ -199,14 +201,14 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
}
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
}
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
@@ -413,7 +415,7 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
}
}
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -427,7 +429,9 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
}
@@ -1377,7 +1381,7 @@ if [ "$1" = "render" ]; then
fi
shift
done
if [ "$prompt" = "weather.three_day_outlook" ]; then
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
printf 'render failed\n' >&2
exit 1
fi