diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index e3fd757..1b060d9 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -6,8 +6,9 @@ membership, and comparison declarations in `internal/report`. ## Purpose `internal/report` centralizes report definitions so report IDs, prompt IDs, -default output names, comparison strategies, and valid periods are declared in -one package. +artifact groups, batch output names, generated-report eligibility, comparison +compatibility, comparison strategies, and valid periods are declared in one +package. ## Inputs And Outputs @@ -32,8 +33,8 @@ Outputs: ## Config Fields Used -The app supplies `weather_api.timezone` as a loaded `time.Location`. Report -output path copying uses default output names from report definitions. +The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch +output path copying uses batch output names from report definitions. ## External Adapters Used @@ -43,7 +44,8 @@ None. None directly. Resolved metadata contributes RunID, report ID, prompt ID, 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 @@ -68,4 +70,6 @@ Inspect: - Report selection goes through the registry. - Daily Today and Daily Tomorrow both use `weather.daily_report`. - 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. diff --git a/docs/internal/state.md b/docs/internal/state.md index 67ab02e..64444a3 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -54,16 +54,19 @@ Workspace subdirectories must be relative paths that stay under ## State Or Manifest Behavior -Managed paths are grouped by report family and valid-period start date for JSON -artifacts. Reports are written under the report group. Metadata is stored beside -briefing snapshots and links briefing, data package, preflight, and report -paths. Report listing walks metadata files under the snapshots directory. +Managed paths are grouped by the report definition's artifact group and +valid-period start date for JSON artifacts. Reports are written under the same +artifact group. Metadata is stored beside briefing snapshots and links +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 -snapshot. Daily Today and Daily Tomorrow are compatible with each other for the -same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for -the same valid local date. Weekend Outlook compares with prior Weekend snapshots -for the same weekend window. Storm Report currently has no prior lookup. +Prior snapshot lookup reads metadata and selects the latest earlier snapshot +whose report ID is compatible according to the current report definition. Daily +Today and Daily Tomorrow are compatible with each other for the same valid local +date. 3-Day Outlook compares with prior 3-Day snapshots for the same valid +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 diff --git a/internal/app/app.go b/internal/app/app.go index afb8629..aa6fefa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" @@ -157,7 +156,7 @@ func Generate(ctx context.Context, req GenerateRequest) error { if err != nil { return err } - if isGeneratedReport(resolved.Definition.ID) { + if resolved.Definition.Generated { _, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, @@ -200,7 +199,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro startedAt := now result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} for _, resolved := range resolvedReports { - if !isGeneratedReport(resolved.Definition.ID) { + if !resolved.Definition.Generated { 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 { - if outputDir == "" || definition.DefaultOutputName == "" { + if outputDir == "" || definition.BatchOutputName == "" { 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 + return filepath.Join(outputDir, definition.BatchOutputName) } func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { diff --git a/internal/report/definition.go b/internal/report/definition.go index fa4623f..6d43289 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -39,6 +39,10 @@ type Definition struct { PromptID string ComparisonStrategy ComparisonStrategy DefaultOutputName string + ArtifactGroup string + BatchOutputName string + Generated bool + CompatiblePriorIDs []ID Morning bool Evening bool resolve func(ResolveRequest) (timeutil.Period, error) @@ -51,6 +55,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { 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 { Now time.Time Location *time.Location diff --git a/internal/report/period_test.go b/internal/report/period_test.go index f512765..eef449c 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -1,6 +1,7 @@ package report import ( + "reflect" "strings" "testing" "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) { location := mustLoadLocation(t) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) diff --git a/internal/report/registry.go b/internal/report/registry.go index cec0cd4..d0bb0e3 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -14,6 +14,10 @@ func DefaultRegistry() Registry { PromptID: "weather.daily_report", ComparisonStrategy: CompareSameValidDate, DefaultOutputName: "daily.md", + ArtifactGroup: "daily", + BatchOutputName: "daily.md", + Generated: true, + CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, Morning: true, resolve: resolveDailyToday, }, @@ -23,6 +27,10 @@ func DefaultRegistry() Registry { PromptID: "weather.daily_report", ComparisonStrategy: CompareSameValidDate, DefaultOutputName: "tomorrow.md", + ArtifactGroup: "daily", + BatchOutputName: "tomorrow.md", + Generated: true, + CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, Evening: true, resolve: resolveDailyTomorrow, }, @@ -32,6 +40,10 @@ func DefaultRegistry() Registry { PromptID: "weather.three_day_outlook", ComparisonStrategy: CompareSameValidDate, DefaultOutputName: "three_day.md", + ArtifactGroup: "three-day", + BatchOutputName: "three-day.md", + Generated: true, + CompatiblePriorIDs: []ID{ThreeDay}, Morning: true, resolve: resolveThreeDay, }, @@ -41,6 +53,10 @@ func DefaultRegistry() Registry { PromptID: "weather.weekend_outlook", ComparisonStrategy: CompareWeekendWindow, DefaultOutputName: "weekend.md", + ArtifactGroup: "weekend", + BatchOutputName: "weekend.md", + Generated: true, + CompatiblePriorIDs: []ID{Weekend}, Morning: true, resolve: resolveWeekend, }, @@ -50,6 +66,10 @@ func DefaultRegistry() Registry { PromptID: "weather.storm_report", ComparisonStrategy: CompareExplicitWindow, DefaultOutputName: "storm.md", + ArtifactGroup: "storm", + BatchOutputName: "storm.md", + Generated: true, + CompatiblePriorIDs: []ID{Storm}, resolve: resolveStorm, }, } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 04922d1..fd7070d 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) if metadata.RunID == "" { return ArtifactPaths{}, fmt.Errorf("run id is required") } - group, err := reportGroup(resolved.Definition.ID) - if err != nil { - return ArtifactPaths{}, err + group := resolved.Definition.ArtifactGroup + if group == "" { + return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) } validDate := resolved.ValidPeriod.Start.Format("2006-01-02") 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 { return nil, nil } - group, err := reportGroup(resolved.Definition.ID) - if err != nil { - return nil, err + group := resolved.Definition.ArtifactGroup + if group == "" { + return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) } dirs, err := s.metadataDirectories(resolved, group) if err != nil { @@ -205,7 +205,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R if metadata.RunID == resolved.Metadata().RunID { continue } - if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { + if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) { continue } if !comparablePeriod(metadata, resolved) { @@ -343,19 +343,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st 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 { all := append([]string{s.root}, parts...) return filepath.Join(all...) @@ -375,21 +362,6 @@ func validateRelativeDir(name string, value string) error { 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 { data, err := json.MarshalIndent(value, "", " ") if err != nil {