Centralize report path policy

This commit is contained in:
2026-05-29 20:28:06 +00:00
parent 6915bf1ba2
commit 7dc2ac9253
7 changed files with 140 additions and 64 deletions

View File

@@ -6,8 +6,9 @@ membership, and comparison declarations in `internal/report`.
## Purpose ## Purpose
`internal/report` centralizes report definitions so report IDs, prompt IDs, `internal/report` centralizes report definitions so report IDs, prompt IDs,
default output names, comparison strategies, and valid periods are declared in artifact groups, batch output names, generated-report eligibility, comparison
one package. compatibility, comparison strategies, and valid periods are declared in one
package.
## Inputs And Outputs ## Inputs And Outputs
@@ -32,8 +33,8 @@ Outputs:
## Config Fields Used ## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Report The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses default output names from report definitions. output path copying uses batch output names from report definitions.
## External Adapters Used ## External Adapters Used
@@ -43,7 +44,8 @@ None.
None directly. Resolved metadata contributes RunID, report ID, prompt ID, None directly. Resolved metadata contributes RunID, report ID, prompt ID,
generation time, timezone, and valid period to later briefing and state generation time, timezone, and valid period to later briefing and state
metadata. metadata. Artifact groups declared by report definitions are used by state path
construction.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -68,4 +70,6 @@ Inspect:
- Report selection goes through the registry. - Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_report`. - Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text. - Valid periods are half-open intervals independent of rendered report text.
- Comparison strategy is declared by report definition. - Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report
definition.

View File

@@ -54,16 +54,19 @@ Workspace subdirectories must be relative paths that stay under
## State Or Manifest Behavior ## State Or Manifest Behavior
Managed paths are grouped by report family and valid-period start date for JSON Managed paths are grouped by the report definition's artifact group and
artifacts. Reports are written under the report group. Metadata is stored beside valid-period start date for JSON artifacts. Reports are written under the same
briefing snapshots and links briefing, data package, preflight, and report artifact group. Metadata is stored beside briefing snapshots and links
paths. Report listing walks metadata files under the snapshots directory. briefing, data package, preflight, and report paths. Report listing walks
metadata files under the snapshots directory.
Prior snapshot lookup reads metadata and selects the latest earlier compatible Prior snapshot lookup reads metadata and selects the latest earlier snapshot
snapshot. Daily Today and Daily Tomorrow are compatible with each other for the whose report ID is compatible according to the current report definition. Daily
same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for Today and Daily Tomorrow are compatible with each other for the same valid local
the same valid local date. Weekend Outlook compares with prior Weekend snapshots date. 3-Day Outlook compares with prior 3-Day snapshots for the same valid
for the same weekend window. Storm Report currently has no prior lookup. local date. Weekend Outlook compares with prior Weekend snapshots for the same
weekend window. Storm Report currently has no prior lookup because its
comparison strategy is not searched by the filesystem store.
## Skip And Resume Behavior ## Skip And Resume Behavior

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -157,7 +156,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil { if err != nil {
return err return err
} }
if isGeneratedReport(resolved.Definition.ID) { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
@@ -200,7 +199,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
@@ -257,19 +256,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" { if outputDir == "" || definition.BatchOutputName == "" {
return "" return ""
} }
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-") return filepath.Join(outputDir, definition.BatchOutputName)
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) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {

View File

@@ -39,6 +39,10 @@ type Definition struct {
PromptID string PromptID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
DefaultOutputName string DefaultOutputName string
ArtifactGroup string
BatchOutputName string
Generated bool
CompatiblePriorIDs []ID
Morning bool Morning bool
Evening bool Evening bool
resolve func(ResolveRequest) (timeutil.Period, error) resolve func(ResolveRequest) (timeutil.Period, error)
@@ -51,6 +55,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
return d.resolve(req) return d.resolve(req)
} }
func (d Definition) CompatibleWithPrior(id ID) bool {
for _, compatibleID := range d.CompatiblePriorIDs {
if id == compatibleID {
return true
}
}
return false
}
type ResolveRequest struct { type ResolveRequest struct {
Now time.Time Now time.Time
Location *time.Location Location *time.Location

View File

@@ -1,6 +1,7 @@
package report package report
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
} }
} }
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
artifactGroup string
batchOutputName string
generated bool
compatiblePriorIDs []ID
}{
{
id: DailyToday,
artifactGroup: "daily",
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: DailyTomorrow,
artifactGroup: "daily",
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: ThreeDay,
artifactGroup: "three-day",
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
},
{
id: Weekend,
artifactGroup: "weekend",
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
},
{
id: Storm,
artifactGroup: "storm",
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if definition.ArtifactGroup != tt.artifactGroup {
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
}
if definition.BatchOutputName != tt.batchOutputName {
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
}
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
for _, id := range tt.compatiblePriorIDs {
if !definition.CompatibleWithPrior(id) {
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
}
}
})
}
}
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})

View File

@@ -14,6 +14,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md", DefaultOutputName: "daily.md",
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Morning: true, Morning: true,
resolve: resolveDailyToday, resolve: resolveDailyToday,
}, },
@@ -23,6 +27,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md", DefaultOutputName: "tomorrow.md",
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Evening: true, Evening: true,
resolve: resolveDailyTomorrow, resolve: resolveDailyTomorrow,
}, },
@@ -32,6 +40,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.three_day_outlook", PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md", DefaultOutputName: "three_day.md",
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Morning: true, Morning: true,
resolve: resolveThreeDay, resolve: resolveThreeDay,
}, },
@@ -41,6 +53,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.weekend_outlook", PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md", DefaultOutputName: "weekend.md",
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Morning: true, Morning: true,
resolve: resolveWeekend, resolve: resolveWeekend,
}, },
@@ -50,6 +66,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.storm_report", PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md", DefaultOutputName: "storm.md",
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
resolve: resolveStorm, resolve: resolveStorm,
}, },
} }

View File

@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
if metadata.RunID == "" { if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required") return ArtifactPaths{}, fmt.Errorf("run id is required")
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return ArtifactPaths{}, err return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID filenameBase := metadata.RunID
@@ -175,9 +175,9 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow { if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return nil, nil return nil, nil
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return nil, err return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
dirs, err := s.metadataDirectories(resolved, group) dirs, err := s.metadataDirectories(resolved, group)
if err != nil { if err != nil {
@@ -205,7 +205,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
if metadata.RunID == resolved.Metadata().RunID { if metadata.RunID == resolved.Metadata().RunID {
continue continue
} }
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
continue continue
} }
if !comparablePeriod(metadata, resolved) { if !comparablePeriod(metadata, resolved) {
@@ -343,19 +343,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
return dirs, nil return dirs, nil
} }
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
switch group {
case "daily":
return prior == report.DailyToday || prior == report.DailyTomorrow
case "three-day":
return prior == report.ThreeDay && current == report.ThreeDay
case "weekend":
return prior == report.Weekend && current == report.Weekend
default:
return false
}
}
func (s *FilesystemStore) join(parts ...string) string { func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...) all := append([]string{s.root}, parts...)
return filepath.Join(all...) return filepath.Join(all...)
@@ -375,21 +362,6 @@ func validateRelativeDir(name string, value string) error {
return nil return nil
} }
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error { func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ") data, err := json.MarshalIndent(value, "", " ")
if err != nil { if err != nil {