Add Daily recent changes comparison
This commit is contained in:
66
docs/internal/changes.md
Normal file
66
docs/internal/changes.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Changes Internals
|
||||||
|
|
||||||
|
This document describes the implemented structured change comparison boundary.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/changes` compares current and prior structured briefing snapshots and
|
||||||
|
produces compact change records for prompt input data packages.
|
||||||
|
|
||||||
|
## Inputs and Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- prior Daily briefing package
|
||||||
|
- current Daily briefing package
|
||||||
|
- configured Recent Changes thresholds
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- ordered `changes.Change` records with type, message, previous value, and
|
||||||
|
current value where useful
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package compares structured briefing data only.
|
||||||
|
- It does not read state directly, render Markdown, invoke `scriptorium`, or
|
||||||
|
compare generated report text.
|
||||||
|
|
||||||
|
## Config Fields Used
|
||||||
|
|
||||||
|
The app maps these config fields into comparison thresholds:
|
||||||
|
|
||||||
|
- `recent_change.temperature_degrees`
|
||||||
|
- `recent_change.precip_probability_points`
|
||||||
|
- `recent_change.wind_gust_miles_per_hour`
|
||||||
|
- `recent_change.precip_timing_shift_minutes`
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Daily comparison currently detects:
|
||||||
|
|
||||||
|
- temperature changes crossing configured thresholds
|
||||||
|
- precipitation probability and timing changes
|
||||||
|
- alert additions and removals
|
||||||
|
- peak wind gust changes
|
||||||
|
- snow, ice, and thunder risk changes
|
||||||
|
|
||||||
|
When no prior comparable snapshot exists, the app sends an empty Recent Changes
|
||||||
|
section in the data package.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
Daily comparison requires both inputs to contain Daily briefing content.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/changes/daily_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Recent Changes are based on structured snapshots, not Markdown report text.
|
||||||
|
- Comparison thresholds come from configuration.
|
||||||
|
- The comparison output remains compact enough for prompt input.
|
||||||
@@ -16,7 +16,7 @@ Input:
|
|||||||
Output:
|
Output:
|
||||||
|
|
||||||
- `promptinput.Package` JSON with report metadata, briefing content, source
|
- `promptinput.Package` JSON with report metadata, briefing content, source
|
||||||
warnings, RunID, and an empty Recent Changes section.
|
warnings, RunID, and a Recent Changes section.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ Output:
|
|||||||
- `promptinput.Validate` rejects missing or inconsistent required fields before
|
- `promptinput.Validate` rejects missing or inconsistent required fields before
|
||||||
render preflight.
|
render preflight.
|
||||||
- `promptinput.Save` writes JSON atomically where practical.
|
- `promptinput.Save` writes JSON atomically where practical.
|
||||||
- Recent Changes is present as an empty `items` list until structured comparison
|
- Recent Changes is present as an `items` list. It is empty when no prior
|
||||||
is implemented.
|
comparable snapshot exists or no meaningful changes are detected.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ the filesystem operation and path context.
|
|||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/promptinput/package_test.go`
|
- `internal/promptinput/package_test.go`
|
||||||
|
- `internal/changes/daily_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Outputs:
|
|||||||
- Markdown report path
|
- Markdown report path
|
||||||
- metadata JSON
|
- metadata JSON
|
||||||
- prior Daily snapshot metadata when available
|
- prior Daily snapshot metadata when available
|
||||||
|
- prior Daily briefing package when loaded by path
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ Workspace subdirectories must be relative paths that stay under
|
|||||||
Managed artifact names use RunID, which is generated from report generation time
|
Managed artifact names use RunID, which is generated from report generation time
|
||||||
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
||||||
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
||||||
date and returns the latest earlier run. The store prepares the managed Markdown
|
date and returns the latest earlier run. The store can load a briefing snapshot
|
||||||
|
by path for structured comparison. The store prepares the managed Markdown
|
||||||
report path before `scriptorium run` writes it.
|
report path before `scriptorium run` writes it.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ Each Daily generation writes metadata that links:
|
|||||||
- preflight output path
|
- preflight output path
|
||||||
- rendered report path
|
- rendered report path
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
|
||||||
|
When a prior comparable Daily briefing snapshot exists for the same valid local
|
||||||
|
date, the app compares structured briefing data before writing the prompt input
|
||||||
|
data package. Meaningful changes are included under `recentChanges.items`.
|
||||||
|
|
||||||
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
|
the Recent Changes list is empty.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
If render preflight exits nonzero after producing a result, the captured stdout,
|
If render preflight exits nonzero after producing a result, the captured stdout,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
@@ -85,6 +86,7 @@ type DailyReportResult struct {
|
|||||||
Metadata state.Metadata
|
Metadata state.Metadata
|
||||||
MetadataPath string
|
MetadataPath string
|
||||||
PriorSnapshot *state.PriorSnapshot
|
PriorSnapshot *state.PriorSnapshot
|
||||||
|
RecentChanges []changes.Change
|
||||||
RenderResult *scriptorium.RenderResult
|
RenderResult *scriptorium.RenderResult
|
||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
}
|
}
|
||||||
@@ -267,7 +269,11 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dataPackage, err := promptinput.Build(briefingPackage)
|
recentChanges, err := dailyRecentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -352,6 +358,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
MetadataPath: metadataPath,
|
MetadataPath: metadataPath,
|
||||||
PriorSnapshot: priorSnapshot,
|
PriorSnapshot: priorSnapshot,
|
||||||
|
RecentChanges: recentChanges,
|
||||||
RenderResult: renderResult,
|
RenderResult: renderResult,
|
||||||
RunResult: runResult,
|
RunResult: runResult,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -386,6 +393,22 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
|||||||
return state.NewFilesystemStore(cfg.Workspace)
|
return state.NewFilesystemStore(cfg.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||||
|
if priorSnapshot == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return changes.CompareDaily(previous, current, changes.Thresholds{
|
||||||
|
TemperatureDegrees: cfg.TemperatureDegrees,
|
||||||
|
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
|
||||||
|
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
|
||||||
|
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func copyFileAtomic(source string, target string) error {
|
func copyFileAtomic(source string, target string) error {
|
||||||
data, err := os.ReadFile(source)
|
data, err := os.ReadFile(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
)
|
)
|
||||||
@@ -222,6 +224,9 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if result.Metadata.RenderedReportPath != result.ReportPath {
|
if result.Metadata.RenderedReportPath != result.ReportPath {
|
||||||
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
|
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
|
||||||
}
|
}
|
||||||
|
if len(result.RecentChanges) != 0 {
|
||||||
|
t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges)
|
||||||
|
}
|
||||||
report, err := os.ReadFile(outputPath)
|
report, err := os.ReadFile(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report output: %v", err)
|
t.Fatalf("read report output: %v", err)
|
||||||
@@ -337,6 +342,78 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T04:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
|
}
|
||||||
|
priorBriefing := priorDailyBriefing(priorResolved)
|
||||||
|
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveBriefing() error = %v", err)
|
||||||
|
}
|
||||||
|
priorPaths, err := store.Paths(priorResolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
|
Briefing: priorBriefingPath,
|
||||||
|
DataPackage: priorPaths.DataPackage,
|
||||||
|
Preflight: priorPaths.Preflight,
|
||||||
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
||||||
|
}
|
||||||
|
renderer := &recordingRenderer{
|
||||||
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||||
|
runBody: "# Daily Report\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: currentResolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
Store: store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.RecentChanges) == 0 {
|
||||||
|
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(result.DataPackagePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read data package: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") {
|
||||||
|
t.Fatalf("data package missing recent changes:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
@@ -435,6 +512,37 @@ func mustParse(value string) time.Time {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||||
|
low := 50.0
|
||||||
|
high := 58.0
|
||||||
|
precip := 10.0
|
||||||
|
return briefing.Package{
|
||||||
|
Metadata: briefing.Metadata{
|
||||||
|
SchemaVersion: briefing.SchemaVersion,
|
||||||
|
RunID: resolved.Metadata().RunID,
|
||||||
|
ReportID: resolved.Definition.ID,
|
||||||
|
Variant: "today",
|
||||||
|
PromptID: resolved.Definition.PromptID,
|
||||||
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
|
Units: "us",
|
||||||
|
Timezone: resolved.Timezone,
|
||||||
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
|
},
|
||||||
|
Daily: &briefing.Daily{
|
||||||
|
BottomLine: briefing.BottomLine{
|
||||||
|
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||||
|
MaxPrecipProbability: &forecast.TimedValue{
|
||||||
|
Value: precip,
|
||||||
|
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dayparts: []forecast.DaypartSummary{
|
||||||
|
{Name: "morning"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type recordingRenderer struct {
|
type recordingRenderer struct {
|
||||||
renderCalls int
|
renderCalls int
|
||||||
runCalls int
|
runCalls int
|
||||||
|
|||||||
201
internal/changes/daily.go
Normal file
201
internal/changes/daily.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Package changes compares structured briefing snapshots.
|
||||||
|
package changes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Thresholds struct {
|
||||||
|
TemperatureDegrees float64
|
||||||
|
PrecipProbabilityPoints int
|
||||||
|
WindGustMilesPerHour int
|
||||||
|
PrecipTimingShiftMinutes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Change struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Previous string `json:"previous,omitempty"`
|
||||||
|
Current string `json:"current,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||||
|
if previous.Daily == nil {
|
||||||
|
return nil, fmt.Errorf("previous daily briefing is required")
|
||||||
|
}
|
||||||
|
if current.Daily == nil {
|
||||||
|
return nil, fmt.Errorf("current daily briefing is required")
|
||||||
|
}
|
||||||
|
var changes []Change
|
||||||
|
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
|
||||||
|
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
|
||||||
|
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
|
||||||
|
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
|
||||||
|
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
|
||||||
|
var changes []Change
|
||||||
|
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
|
||||||
|
changes = append(changes, Change{
|
||||||
|
Type: "temperature_shift",
|
||||||
|
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
|
||||||
|
Previous: fmt.Sprintf("%.0f", *previous.Min),
|
||||||
|
Current: fmt.Sprintf("%.0f", *current.Min),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
|
||||||
|
changes = append(changes, Change{
|
||||||
|
Type: "temperature_shift",
|
||||||
|
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
|
||||||
|
Previous: fmt.Sprintf("%.0f", *previous.Max),
|
||||||
|
Current: fmt.Sprintf("%.0f", *current.Max),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
|
||||||
|
if previous == nil || current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var changes []Change
|
||||||
|
previousCategory := precipitationCategory(previous.Value)
|
||||||
|
currentCategory := precipitationCategory(current.Value)
|
||||||
|
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
|
||||||
|
changes = append(changes, Change{
|
||||||
|
Type: "precip_probability_change",
|
||||||
|
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
|
||||||
|
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
|
||||||
|
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
|
||||||
|
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
|
||||||
|
changes = append(changes, Change{
|
||||||
|
Type: "precip_timing_shift",
|
||||||
|
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
|
||||||
|
Previous: clock(previous.Time),
|
||||||
|
Current: clock(current.Time),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
|
||||||
|
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Change{{
|
||||||
|
Type: "wind_gust_change",
|
||||||
|
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
|
||||||
|
Previous: fmt.Sprintf("%.0f mph", previous.Value),
|
||||||
|
Current: fmt.Sprintf("%.0f mph", current.Value),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
|
||||||
|
previousSet := alertSet(previous)
|
||||||
|
currentSet := alertSet(current)
|
||||||
|
var changes []Change
|
||||||
|
for event := range currentSet {
|
||||||
|
if _, ok := previousSet[event]; !ok {
|
||||||
|
changes = append(changes, Change{Type: "alert_added", Message: fmt.Sprintf("Alert added: %s.", event), Current: event})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for event := range previousSet {
|
||||||
|
if _, ok := currentSet[event]; !ok {
|
||||||
|
changes = append(changes, Change{Type: "alert_removed", Message: fmt.Sprintf("Alert removed: %s.", event), Previous: event})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortChanges(changes)
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
||||||
|
var changes []Change
|
||||||
|
for _, item := range []struct {
|
||||||
|
name string
|
||||||
|
previous bool
|
||||||
|
current bool
|
||||||
|
}{
|
||||||
|
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
||||||
|
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||||
|
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||||
|
} {
|
||||||
|
if item.previous == item.current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changeType := item.name + "_risk_change"
|
||||||
|
if item.current {
|
||||||
|
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
||||||
|
} else {
|
||||||
|
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is no longer present.", item.name), Previous: "present"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||||
|
out := forecast.Indicators{}
|
||||||
|
for _, daypart := range dayparts {
|
||||||
|
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
||||||
|
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||||
|
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, alert := range alerts {
|
||||||
|
event := alert.Event
|
||||||
|
if event == "" {
|
||||||
|
event = alert.Headline
|
||||||
|
}
|
||||||
|
if event != "" {
|
||||||
|
out[event] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipitationCategory(value float64) string {
|
||||||
|
switch {
|
||||||
|
case value >= 70:
|
||||||
|
return "high"
|
||||||
|
case value >= 50:
|
||||||
|
return "likely"
|
||||||
|
case value >= 20:
|
||||||
|
return "possible"
|
||||||
|
default:
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func differenceAtLeast(previous float64, current float64, threshold float64) bool {
|
||||||
|
if threshold <= 0 {
|
||||||
|
return previous != current
|
||||||
|
}
|
||||||
|
return math.Abs(current-previous) >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func clock(t time.Time) string {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortChanges(items []Change) {
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].Type == items[j].Type {
|
||||||
|
return items[i].Message < items[j].Message
|
||||||
|
}
|
||||||
|
return items[i].Type < items[j].Type
|
||||||
|
})
|
||||||
|
}
|
||||||
123
internal/changes/daily_test.go
Normal file
123
internal/changes/daily_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package changes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||||
|
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
||||||
|
|
||||||
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changes) != 0 {
|
||||||
|
t.Fatalf("changes = %#v, want none", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||||
|
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
|
||||||
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if countType(changes, "temperature_shift") != 2 {
|
||||||
|
t.Fatalf("changes = %#v, want low and high temperature changes", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||||
|
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
|
||||||
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if countType(changes, "precip_timing_shift") != 1 {
|
||||||
|
t.Fatalf("changes = %#v, want timing shift", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||||
|
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
||||||
|
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
||||||
|
|
||||||
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if countType(changes, "alert_added") != 1 || countType(changes, "alert_removed") != 1 {
|
||||||
|
t.Fatalf("changes = %#v, want one alert added and one removed", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||||
|
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||||
|
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
|
||||||
|
|
||||||
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if countType(changes, "thunder_risk_change") != 1 {
|
||||||
|
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
|
||||||
|
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
||||||
|
for _, alert := range alerts {
|
||||||
|
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
||||||
|
}
|
||||||
|
return briefing.Package{
|
||||||
|
Daily: &briefing.Daily{
|
||||||
|
BottomLine: briefing.BottomLine{
|
||||||
|
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||||
|
MaxPrecipProbability: &forecast.TimedValue{
|
||||||
|
Value: precip,
|
||||||
|
Time: precipTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RelevantAlerts: alertOverlaps,
|
||||||
|
Dayparts: []forecast.DaypartSummary{
|
||||||
|
{Name: "morning", Indicators: indicators},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThresholds() Thresholds {
|
||||||
|
return Thresholds{
|
||||||
|
TemperatureDegrees: 5,
|
||||||
|
PrecipProbabilityPoints: 20,
|
||||||
|
WindGustMilesPerHour: 10,
|
||||||
|
PrecipTimingShiftMinutes: 120,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countType(changes []Change, changeType string) int {
|
||||||
|
var count int
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.Type == changeType {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func at(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -35,15 +36,19 @@ type Report struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecentChanges struct {
|
type RecentChanges struct {
|
||||||
Items []Change `json:"items"`
|
Items []changes.Change `json:"items"`
|
||||||
}
|
|
||||||
|
|
||||||
type Change struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Build(briefingPackage briefing.Package) (Package, error) {
|
func Build(briefingPackage briefing.Package) (Package, error) {
|
||||||
|
return BuildWithRecentChanges(briefingPackage, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||||
|
items := make([]changes.Change, len(recentChanges))
|
||||||
|
copy(items, recentChanges)
|
||||||
|
if items == nil {
|
||||||
|
items = []changes.Change{}
|
||||||
|
}
|
||||||
pkg := Package{
|
pkg := Package{
|
||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: SchemaVersion,
|
||||||
RunID: briefingPackage.Metadata.RunID,
|
RunID: briefingPackage.Metadata.RunID,
|
||||||
@@ -56,7 +61,7 @@ func Build(briefingPackage briefing.Package) (Package, error) {
|
|||||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||||
},
|
},
|
||||||
Briefing: briefingPackage,
|
Briefing: briefingPackage,
|
||||||
RecentChanges: RecentChanges{Items: []Change{}},
|
RecentChanges: RecentChanges{Items: items},
|
||||||
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
|
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
|
||||||
}
|
}
|
||||||
if err := Validate(pkg); err != nil {
|
if err := Validate(pkg); err != nil {
|
||||||
|
|||||||
@@ -294,3 +294,14 @@ func metadataPathFromStored(metadata Metadata) string {
|
|||||||
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
|
||||||
|
if path == "" {
|
||||||
|
return briefing.Package{}, fmt.Errorf("briefing path is required")
|
||||||
|
}
|
||||||
|
var pkg briefing.Package
|
||||||
|
if err := readJSON(path, &pkg); err != nil {
|
||||||
|
return briefing.Package{}, err
|
||||||
|
}
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadBriefing() error = %v", err)
|
||||||
|
}
|
||||||
|
if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID {
|
||||||
|
t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID)
|
||||||
|
}
|
||||||
var decoded Metadata
|
var decoded Metadata
|
||||||
data, err := os.ReadFile(metadataPath)
|
data, err := os.ReadFile(metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,6 +153,39 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||||
|
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
briefingPackage := stateBriefingPackage(previousDate)
|
||||||
|
briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveBriefing() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := store.Paths(previousDate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, briefingPackage, ArtifactPaths{
|
||||||
|
Briefing: briefingPath,
|
||||||
|
Metadata: paths.Metadata,
|
||||||
|
DataPackage: paths.DataPackage,
|
||||||
|
Preflight: paths.Preflight,
|
||||||
|
RenderedReport: paths.RenderedReport,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
if prior != nil {
|
||||||
|
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||||
cfg := config.Defaults().Workspace
|
cfg := config.Defaults().Workspace
|
||||||
cfg.Root = t.TempDir()
|
cfg.Root = t.TempDir()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Store interface {
|
|||||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
|
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriorSnapshot struct {
|
type PriorSnapshot struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user