Add Today generate command workflow

This commit is contained in:
2026-06-15 14:58:36 +00:00
parent 3d5f71e72d
commit 4a0238909b
5 changed files with 314 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ the managed Markdown report after final metadata is saved.
```text
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
@@ -38,15 +39,15 @@ weatherreporter inspect sources [--config PATH] RUN_ID
Implemented `generate` commands write a JSON module snapshot, YAML data package,
preflight artifact, managed Markdown report, and metadata under the configured
workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate
tomorrow` and `generate hourly` write managed generated-text artifacts,
notification uses the managed report path, not the extra copy. `generate today`,
`generate tomorrow`, and `generate hourly` write managed generated-text artifacts,
validate structured text from Scriptorium, and render the managed Markdown
report from embedded templates. `generate hourly` covers the next six hours in
the effective report timezone and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and
`--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Report. Batch
runs continue independent reports after a failure, print a JSON summary to
stdout, write compact status lines to stderr, and return nonzero when any report
@@ -70,7 +71,7 @@ They do not fetch weather data or invoke `scriptorium`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--date YYYY-MM-DD`: optional date for `generate daily` and `generate today`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
@@ -85,6 +86,7 @@ are no distributor-specific CLI flags.
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate today --out ./today.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md

View File

@@ -30,6 +30,7 @@ type ReportKind string
const (
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
ReportToday ReportKind = ReportKind(report.CommandNameToday)
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)

View File

@@ -597,6 +597,108 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
}
}
func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportToday,
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)
}
outputPath := filepath.Join(t.TempDir(), "today-copy.md")
notifier := &recordingNotifier{
result: &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
},
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTodayGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("output copy differs from managed report")
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportID != report.Today {
t.Fatalf("notification ReportID = %q, want today", req.ReportID)
}
if req.ReportPath != result.ReportPath {
t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
if req.PipelineID != "weatherreporter.today.today" {
t.Fatalf("PipelineID = %q, want Today report and artifact values", req.PipelineID)
}
if req.BundleID != "today.today.md.today" {
t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID)
}
wantBundlePaths := []string{"2026-05-29/today/today.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notificationArtifact.ReportID != report.Today ||
notificationArtifact.PipelineID != "weatherreporter.today.today" ||
notificationArtifact.BundleID != "today.today.md.today" ||
notificationArtifact.SourcePath != result.ReportPath ||
strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") ||
notificationArtifact.RunStatus == nil ||
!strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
t.Fatalf("notification artifact = %#v, want Today managed-source notification", notificationArtifact)
}
}
func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.err = errors.New("upload rejected")
@@ -1350,7 +1452,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportKind(report.CommandNameToday),
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
@@ -1360,7 +1462,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportKind(report.CommandNameToday),
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
@@ -2302,6 +2404,49 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
}
}
func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
OutputDir: outputDir,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 3 {
t.Fatalf("summary = %#v, want successful morning batch", result)
}
var todayItem *BatchReportResult
for i := range result.Reports {
if result.Reports[i].ReportID == report.Today {
todayItem = &result.Reports[i]
break
}
}
if todayItem == nil {
t.Fatalf("reports = %#v, want Today item", result.Reports)
}
want := filepath.Join(outputDir, "today.md")
if todayItem.OutputPath != want {
t.Fatalf("Today OutputPath = %q, want %q", todayItem.OutputPath, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected Today output copy %q: %v", want, err)
}
if strings.Contains(todayItem.ReportPath, outputDir) {
t.Fatalf("Today ReportPath = %q, want managed report path separate from output copy", todayItem.ReportPath)
}
}
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
definition, err := report.DefaultRegistry().Lookup(report.Hourly)
if err != nil {

View File

@@ -18,6 +18,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage:
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
@@ -216,7 +217,7 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
}
switch reportKind {
case app.ReportDaily:
case app.ReportDaily, app.ReportToday:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
@@ -278,7 +279,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
fs.SetOutput(io.Discard)
opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true)
if report == app.ReportDaily {
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
if report == app.ReportStorm {

View File

@@ -26,6 +26,9 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(output.stdout, "generate daily") {
t.Fatalf("help output missing generate command:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "generate today") {
t.Fatalf("help output missing today generate command:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
}
@@ -570,6 +573,66 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
}
}
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "today.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "today",
"--config", configPath,
"--date", "2026-05-29",
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{
"# Today's Weather",
"Today starts with showers before improving.",
"Morning showers should taper as drier air arrives.",
} {
if !strings.Contains(string(report), want) {
t.Fatalf("today report output missing %q:\n%s", want, string(report))
}
}
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(dataPackage), "id: today") ||
!strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") ||
!strings.Contains(string(dataPackage), "today_planning:") {
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
if len(dailyPackages) != 0 {
t.Fatalf("daily packages = %#v, want generate today to stay separate", dailyPackages)
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "today", "*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
assertFileContains(t, managedReportPath, "# Today's Weather")
}
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -627,6 +690,65 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
assertFileContains(t, managedReportPath, "# Hourly Report")
}
func TestRunInspectTodayArtifacts(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{
"generate", "today",
"--config", configPath,
"--date", "2026-05-29",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(generate) error = %v", err)
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(inspect reports) error = %v", err)
}
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"reportId": "today"`) {
t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String())
}
var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(inspect %s) error = %v", command, err)
}
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") {
t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String())
}
if command == "sources" {
sourcesOutput = stdout.String()
}
}
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
}
}
func TestRunInspectGeneratedArtifacts(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -753,6 +875,7 @@ func TestResolveGenerateCommands(t *testing.T) {
want app.ReportKind
}{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
@@ -778,7 +901,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
for _, name := range report.CommandNames() {
t.Run(name, func(t *testing.T) {
args := []string{name}
if name == report.CommandNameDaily {
if name == report.CommandNameDaily || name == report.CommandNameToday {
args = append(args, "--date", "2026-05-29")
}
if name == report.CommandNameStorm {
@@ -865,6 +988,39 @@ func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
}
}
func TestResolveGenerateTodayDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
defaultReq, err := runner.resolveGenerate([]string{"today"})
if err != nil {
t.Fatalf("resolveGenerate(default) error = %v", err)
}
if defaultReq.Report != app.ReportToday {
t.Fatalf("Report = %q, want today", defaultReq.Report)
}
if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
t.Fatalf("default Date = %s, want 2026-05-29", got)
}
explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"})
if err != nil {
t.Fatalf("resolveGenerate(explicit) error = %v", err)
}
if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
t.Fatalf("explicit Date = %s, want 2026-05-30", got)
}
resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.Today {
t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID)
}
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" {
t.Fatalf("valid period start = %s, want explicit UTC date", got)
}
}
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}