Centralize report path policy
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user