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
`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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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})

View File

@@ -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,
},
}

View File

@@ -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 {