From cf8e1de3ff3fc2cd096a31cd59e1342f3374b3c4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:23:49 +0000 Subject: [PATCH] Add Daily briefing JSON generation --- README.md | 6 +- docs/cli.md | 18 +-- docs/internal/briefing.md | 59 +++++++ internal/app/app.go | 75 ++++++++- internal/app/app_test.go | 89 ++++++++++ internal/briefing/daily.go | 278 ++++++++++++++++++++++++++++++++ internal/briefing/daily_test.go | 198 +++++++++++++++++++++++ internal/briefing/package.go | 156 ++++++++++++++++++ internal/cli/root_test.go | 57 +++++++ 9 files changed, 922 insertions(+), 14 deletions(-) create mode 100644 docs/internal/briefing.md create mode 100644 internal/briefing/daily.go create mode 100644 internal/briefing/daily_test.go create mode 100644 internal/briefing/package.go diff --git a/README.md b/README.md index 66b0434..2f5ba89 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application currently resolves configuration and CLI requests. Weather API -fetching, report generation, and `scriptorium` rendering are tracked in the -roadmap and are not implemented yet. +The application can currently produce a Daily briefing JSON artifact. Rendered +reports and `scriptorium` execution are tracked in the roadmap and are not +implemented yet. ## Quickstart diff --git a/docs/cli.md b/docs/cli.md index d2f4a98..61754ec 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,8 +1,8 @@ # Weatherreporter CLI -`weatherreporter` currently resolves configuration, command requests, report -definitions, and valid periods, then returns a not-implemented error for report -generation and scheduled runs. +`weatherreporter generate daily` currently writes a Daily briefing JSON artifact. +Other report generation and scheduled runs still resolve configuration, report +definitions, and valid periods, then return a not-implemented error. ## Shortest Useful Command @@ -10,8 +10,8 @@ generation and scheduled runs. weatherreporter generate daily --date 2026-05-29 --out ./daily.md ``` -The command parses flags, loads configuration, resolves report identity and the -valid period, and then stops before weather data fetching or report rendering. +The command parses flags, loads configuration, fetches weather data, builds a +Daily briefing, and writes the JSON artifact to `--out`. ## Command Overview @@ -25,9 +25,9 @@ weatherreporter run morning weatherreporter run evening ``` -`generate` commands resolve one report request. `run` commands resolve a -scheduled batch request. All report and batch execution currently returns -`not implemented` after request resolution. +`generate daily` writes a briefing JSON artifact. Other `generate` commands +resolve one report request and stop before report generation. `run` commands +resolve a scheduled batch request and stop before execution. ## Flags @@ -35,7 +35,7 @@ scheduled batch request. All report and batch execution currently returns - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--units VALUE`: override configured Weather API units. - `--tz NAME`: override configured Weather API timezone. -- `--out PATH`: override report output path or directory for `generate` commands. +- `--out PATH`: output path for `generate daily`; reserved for later generated report output on other `generate` commands. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md new file mode 100644 index 0000000..298d17b --- /dev/null +++ b/docs/internal/briefing.md @@ -0,0 +1,59 @@ +# Briefing Internals + +This document describes the implemented briefing package boundary. + +## Purpose + +`internal/briefing` builds structured report-specific briefing packages from +forecast summaries and report metadata. The package currently implements the +Daily briefing only. + +## Inputs and Outputs + +Inputs: + +- resolved report definition and valid period +- forecast bundle +- derived daily forecast summary +- configured units and timezone + +Output: + +- `briefing.Package` JSON containing common metadata and Daily briefing content + +## Boundaries + +- Briefings are structured weather facts and context for later prompt input. +- This package does not fetch weather data, compare prior snapshots, build + `scriptorium` data packages, or render final report prose. + +## Behavior + +- Common metadata includes schema version, RunID, report ID, variant, prompt ID, + generation time, units, timezone, valid period, source location, source + provenance, hashes, and source warnings. +- Daily content includes bottom-line inputs, daypart summaries, relevant alerts, + outdoor window inputs, narrative periods, discussion context, and weather + story context when available. +- Briefing JSON is written atomically by `briefing.Save`. + +## Failure Behavior + +- Daily briefing construction requires a Daily report definition and a derived + daily forecast summary. +- Save failures include path and operation context. + +## Tests + +Inspect: + +- `internal/briefing/daily_test.go` +- `internal/app/app_test.go` +- `internal/cli/root_test.go` + +## Invariants + +- Weather facts come from normalized and derived source data. +- Briefing output remains JSON-inspectable. +- LLM prompt input packaging and `scriptorium` execution remain outside this + boundary. diff --git a/internal/app/app.go b/internal/app/app.go index 29e36f5..acd4339 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,9 +4,11 @@ package app import ( "context" "fmt" + "path/filepath" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi" + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" @@ -49,9 +51,28 @@ type FetchBundleRequest struct { OutputPath string } +type DailyBriefingRequest struct { + Config config.Config + Resolved report.Resolved + OutputPath string +} + +type DailyBriefingResult struct { + Package briefing.Package + OutputPath string +} + func Generate(ctx context.Context, req GenerateRequest) error { - _ = ctx - if _, err := ResolveGenerate(req, time.Now()); err != nil { + resolved, err := ResolveGenerate(req, time.Now()) + if err != nil { + return err + } + if resolved.Definition.ID == report.DailyToday { + _, err := GenerateDailyBriefing(ctx, DailyBriefingRequest{ + Config: req.Config, + Resolved: resolved, + OutputPath: req.OutputPath, + }) return err } return fmt.Errorf("generate is not implemented") @@ -151,3 +172,53 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast. } return bundle, nil } + +func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) { + bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) + if err != nil { + return nil, err + } + pkg, err := BuildDailyBriefing(req, bundle) + if err != nil { + return nil, err + } + outputPath := req.OutputPath + if outputPath == "" { + outputPath = defaultBriefingPath(req.Config, req.Resolved) + } + if err := briefing.Save(outputPath, pkg); err != nil { + return nil, err + } + return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil +} + +func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { + location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) + if err != nil { + return briefing.Package{}, err + } + dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts)) + for _, daypart := range req.Config.Dayparts { + dayparts = append(dayparts, forecast.DaypartDefinition{ + Name: daypart.Name, + Start: daypart.Start, + End: daypart.End, + }) + } + summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) + if err != nil { + return briefing.Package{}, err + } + return briefing.BuildDaily(briefing.BuildContext{ + Resolved: req.Resolved, + Bundle: bundle, + Units: req.Config.WeatherAPI.Units, + Timezone: req.Config.WeatherAPI.Timezone, + }, summary) +} + +func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string { + validDate := resolved.ValidPeriod.Start.Format("2006-01-02") + filename := validDate + "." + string(resolved.Definition.ID) + ".briefing.json" + return filepath.Join(cfg.Workspace.Root, cfg.Workspace.SnapshotsDir, "daily", validDate, filename) +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 158010a..eaa7739 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -65,6 +65,71 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { } } +func TestGenerateDailyBriefingWritesArtifact(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + resolved, 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() error = %v", err) + } + path := filepath.Join(t.TempDir(), "daily.briefing.json") + + result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: path, + }) + if err != nil { + t.Fatalf("GenerateDailyBriefing() error = %v", err) + } + if result.OutputPath != path { + t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path) + } + if result.Package.Daily == nil { + t.Fatal("Daily = nil") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read briefing artifact: %v", err) + } + if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) { + t.Fatalf("briefing artifact missing expected fields:\n%s", string(data)) + } +} + +func TestGenerateDailyBriefingDefaultPath(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + resolved, 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() error = %v", err) + } + + result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ + Config: cfg, + Resolved: resolved, + }) + if err != nil { + t.Fatalf("GenerateDailyBriefing() error = %v", err) + } + if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "2026-05-29.daily_today.briefing.json")) { + t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath) + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -88,6 +153,30 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { } } +func dailyBundleServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/observations": + _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) + case "/conditions/current": + _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) + case "/forecast/hourly": + _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) + case "/forecast/narrative": + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`)) + case "/alerts/active": + _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) + case "/discussion": + _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + func TestResolveGenerateStorm(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go new file mode 100644 index 0000000..9f6adfb --- /dev/null +++ b/internal/briefing/daily.go @@ -0,0 +1,278 @@ +package briefing + +import ( + "fmt" + "math" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +type Daily struct { + BottomLine BottomLine `json:"bottomLine"` + Dayparts []forecast.DaypartSummary `json:"dayparts"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + OutdoorWindows OutdoorWindows `json:"outdoorWindows"` + NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` + Discussion DiscussionContext `json:"discussion,omitempty"` + WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` + ForecastSummaryDate string `json:"forecastSummaryDate"` +} + +type BottomLine struct { + Summary string `json:"summary"` + Hazards []string `json:"hazards,omitempty"` + Temperature forecast.Range `json:"temperature,omitempty"` + MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"` + PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"` +} + +type OutdoorWindows struct { + Best *OutdoorWindow `json:"best,omitempty"` + Worst *OutdoorWindow `json:"worst,omitempty"` +} + +type OutdoorWindow struct { + Daypart string `json:"daypart"` + Start string `json:"start"` + End string `json:"end"` + Reasons []string `json:"reasons,omitempty"` + Score float64 `json:"score"` +} + +type DiscussionContext struct { + Product string `json:"product,omitempty"` + KeyMessages []string `json:"keyMessages,omitempty"` + ShortTerm string `json:"shortTerm,omitempty"` + LongTerm string `json:"longTerm,omitempty"` +} + +type WeatherStoryContext struct { + Available bool `json:"available"` + Summary string `json:"summary,omitempty"` +} + +func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) { + if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow { + return Package{}, fmt.Errorf("daily briefing requires a daily report definition") + } + if summary == nil { + return Package{}, fmt.Errorf("daily forecast summary is required") + } + pkg := Package{ + Metadata: BuildMetadata(ctx), + Daily: &Daily{ + BottomLine: buildBottomLine(summary), + Dayparts: summary.Dayparts, + RelevantAlerts: summary.AlertOverlaps, + OutdoorWindows: buildOutdoorWindows(summary.Dayparts), + NarrativePeriods: summary.NarrativePeriods, + Discussion: buildDiscussion(summary.Discussion), + WeatherStory: buildWeatherStory(ctx.Bundle), + ForecastSummaryDate: summary.Date, + }, + } + return pkg, nil +} + +func buildBottomLine(summary *forecast.DailySummary) BottomLine { + bottomLine := BottomLine{} + conditions := map[string]struct{}{} + hazards := map[string]struct{}{} + for _, daypart := range summary.Dayparts { + addRange(&bottomLine.Temperature, daypart.Temperature) + maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability) + maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust) + if daypart.DominantCondition != "" { + conditions[daypart.DominantCondition] = struct{}{} + } + for _, hazard := range hazardsForIndicators(daypart.Indicators) { + hazards[hazard] = struct{}{} + } + } + for _, alert := range summary.AlertOverlaps { + if alert.Event != "" { + hazards[alert.Event] = struct{}{} + } + } + bottomLine.Hazards = sortedSet(hazards) + bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards) + return bottomLine +} + +func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { + var best *OutdoorWindow + var worst *OutdoorWindow + for _, daypart := range dayparts { + if len(daypart.HourlyPeriods) == 0 { + continue + } + window := scoreOutdoorWindow(daypart) + if best == nil || window.Score < best.Score { + copied := window + best = &copied + } + if worst == nil || window.Score > worst.Score { + copied := window + worst = &copied + } + } + return OutdoorWindows{Best: best, Worst: worst} +} + +func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { + if discussion == nil { + return DiscussionContext{} + } + ctx := DiscussionContext{ + Product: discussion.Product, + KeyMessages: discussion.KeyMessages, + } + if discussion.ShortTerm != nil { + ctx.ShortTerm = discussion.ShortTerm.Narrative + } + if discussion.LongTerm != nil { + ctx.LongTerm = discussion.LongTerm.Narrative + } + return ctx +} + +func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext { + if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 { + return nil + } + return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)} +} + +func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { + score := 0.0 + reasons := []string{} + if daypart.MaxPrecipitationProbability != nil { + score += daypart.MaxPrecipitationProbability.Value + if daypart.MaxPrecipitationProbability.Value >= 50 { + reasons = append(reasons, "high precipitation chance") + } + } + if daypart.PeakWindGust != nil { + score += daypart.PeakWindGust.Value * 1.5 + if daypart.PeakWindGust.Value >= 30 { + reasons = append(reasons, "gusty wind") + } + } + if len(daypart.AlertOverlaps) > 0 { + score += float64(len(daypart.AlertOverlaps)) * 100 + reasons = append(reasons, "alert overlap") + } + if daypart.Indicators.Thunder { + score += 75 + reasons = append(reasons, "thunder risk") + } + if daypart.Indicators.Heat || daypart.Indicators.Cold { + score += 25 + if daypart.Indicators.Heat { + reasons = append(reasons, "heat risk") + } + if daypart.Indicators.Cold { + reasons = append(reasons, "cold risk") + } + } + if len(reasons) == 0 { + reasons = append(reasons, "quiet weather") + } + return OutdoorWindow{ + Daypart: daypart.Name, + Start: daypart.Period.Start.Format("15:04"), + End: daypart.Period.End.Format("15:04"), + Reasons: dedupe(reasons), + Score: math.Round(score*10) / 10, + } +} + +func bottomLineText(conditions []string, hazards []string) string { + if len(conditions) == 0 && len(hazards) == 0 { + return "Quiet weather is expected." + } + parts := []string{} + if len(conditions) > 0 { + parts = append(parts, "Conditions: "+strings.Join(conditions, "; ")) + } + if len(hazards) > 0 { + parts = append(parts, "Watch points: "+strings.Join(hazards, "; ")) + } + return strings.Join(parts, ". ") + "." +} + +func hazardsForIndicators(indicators forecast.Indicators) []string { + var hazards []string + if indicators.Thunder { + hazards = append(hazards, "thunder") + } + if indicators.Snow { + hazards = append(hazards, "snow") + } + if indicators.Ice { + hazards = append(hazards, "ice") + } + if indicators.Fog { + hazards = append(hazards, "fog") + } + if indicators.Heat { + hazards = append(hazards, "heat") + } + if indicators.Cold { + hazards = append(hazards, "cold") + } + if indicators.Wind { + hazards = append(hazards, "wind") + } + return hazards +} + +func addRange(target *forecast.Range, value forecast.Range) { + if value.Min != nil { + if target.Min == nil || *value.Min < *target.Min { + copied := *value.Min + target.Min = &copied + } + } + if value.Max != nil { + if target.Max == nil || *value.Max > *target.Max { + copied := *value.Max + target.Max = &copied + } + } +} + +func maxTimedValue(target **forecast.TimedValue, value *forecast.TimedValue) { + if value == nil { + return + } + if *target == nil || value.Value > (*target).Value { + copied := *value + *target = &copied + } +} + +func sortedSet(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func dedupe(values []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go new file mode 100644 index 0000000..3ee2b8e --- /dev/null +++ b/internal/briefing/daily_test.go @@ -0,0 +1,198 @@ +package briefing + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { + bundle := loadBundleFixture(t) + bundle.Sources[0].DataSHA256 = "abc123" + bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} + location := mustLocation(t) + resolved := mustResolveDaily(t, location) + summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + + pkg, err := BuildDaily(BuildContext{ + Resolved: resolved, + Bundle: bundle, + Units: "us", + Timezone: "America/Chicago", + }, summary) + if err != nil { + t.Fatalf("BuildDaily() error = %v", err) + } + + if pkg.Metadata.SchemaVersion != SchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion) + } + if !strings.Contains(pkg.Metadata.RunID, "daily_today") { + t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID) + } + if pkg.Metadata.ReportID != report.DailyToday { + t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID) + } + if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { + t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) + } + if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { + t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) + } + if len(pkg.Metadata.SourceWarnings) != 1 { + t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings)) + } + if pkg.Daily == nil { + t.Fatal("Daily = nil") + } + if len(pkg.Daily.Dayparts) != 4 { + t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts)) + } + if len(pkg.Daily.RelevantAlerts) != 1 { + t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts)) + } + if len(pkg.Daily.NarrativePeriods) != 1 { + t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods)) + } + if len(pkg.Daily.Discussion.KeyMessages) != 1 { + t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages)) + } + if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil { + t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows) + } + if pkg.Daily.BottomLine.Summary == "" { + t.Fatal("BottomLine summary is empty") + } + if _, err := json.Marshal(pkg); err != nil { + t.Fatalf("briefing package is not JSON inspectable: %v", err) + } +} + +func TestDailyBriefingQuietWeather(t *testing.T) { + location := mustLocation(t) + resolved := mustResolveDaily(t, location) + bundle := &forecast.Bundle{ + Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ + quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), + }}, + Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, + } + summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) + if err != nil { + t.Fatalf("BuildDaily() error = %v", err) + } + if pkg.Daily.BottomLine.Summary != "Conditions: Clear." { + t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary) + } + if len(pkg.Daily.RelevantAlerts) != 0 { + t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) + } +} + +func TestDailyBriefingAlertExclusion(t *testing.T) { + location := mustLocation(t) + resolved := mustResolveDaily(t, location) + bundle := loadBundleFixture(t) + bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`), + }} + summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) + if err != nil { + t.Fatalf("BuildDaily() error = %v", err) + } + if len(pkg.Daily.RelevantAlerts) != 0 { + t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) + } +} + +func TestSaveBriefingPackage(t *testing.T) { + pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}} + path := filepath.Join(t.TempDir(), "nested", "briefing.json") + if err := Save(path, pkg); err != nil { + t.Fatalf("Save() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read briefing: %v", err) + } + if !strings.Contains(string(data), SchemaVersion) { + t.Fatalf("saved briefing missing schema version:\n%s", string(data)) + } +} + +func loadBundleFixture(t *testing.T) *forecast.Bundle { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) + if err != nil { + t.Fatalf("read bundle fixture: %v", err) + } + var bundle forecast.Bundle + if err := json.Unmarshal(data, &bundle); err != nil { + t.Fatalf("decode bundle fixture: %v", err) + } + return &bundle +} + +func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved { + t.Helper() + resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{ + Now: mustParse("2026-05-29T05:00:00-05:00"), + Location: location, + }) + if err != nil { + t.Fatalf("resolve daily: %v", err) + } + return resolved +} + +func defaultDayparts() []forecast.DaypartDefinition { + return []forecast.DaypartDefinition{ + {Name: "overnight", Start: "00:00", End: "06:00"}, + {Name: "morning", Start: "06:00", End: "12:00"}, + {Name: "afternoon", Start: "12:00", End: "18:00"}, + {Name: "evening", Start: "18:00", End: "24:00"}, + } +} + +func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod { + return forecast.ForecastPeriod{ + StartTime: mustParse(start), + EndTime: mustParse(end), + TextDescription: "Clear", + TemperatureF: &temperature, + } +} + +func mustLocation(t *testing.T) *time.Location { + t.Helper() + location, err := time.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("load location: %v", err) + } + return location +} + +func mustParse(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/briefing/package.go b/internal/briefing/package.go new file mode 100644 index 0000000..5a68929 --- /dev/null +++ b/internal/briefing/package.go @@ -0,0 +1,156 @@ +// Package briefing builds report-specific structured briefing packages. +package briefing + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const SchemaVersion = "weatherreporter.briefing.v1" + +type Package struct { + Metadata Metadata `json:"metadata"` + Daily *Daily `json:"daily,omitempty"` +} + +type Metadata struct { + SchemaVersion string `json:"schemaVersion"` + RunID string `json:"runId"` + ReportID report.ID `json:"reportId"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Units string `json:"units"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` + SourceLocationID string `json:"sourceLocationId,omitempty"` + SourceLocation string `json:"sourceLocation,omitempty"` + Sources []SourceMetadata `json:"sources,omitempty"` + SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` +} + +type SourceMetadata struct { + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + DataSHA256 string `json:"dataSha256,omitempty"` + Missing bool `json:"missing,omitempty"` + Warnings []forecast.SourceWarning `json:"warnings,omitempty"` +} + +type BuildContext struct { + Resolved report.Resolved + Bundle *forecast.Bundle + Units string + Timezone string +} + +func BuildMetadata(ctx BuildContext) Metadata { + metadata := ctx.Resolved.Metadata() + sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) + return Metadata{ + SchemaVersion: SchemaVersion, + RunID: metadata.RunID, + ReportID: metadata.ReportID, + Variant: variantForReport(metadata.ReportID), + PromptID: metadata.PromptID, + GeneratedAt: metadata.GeneratedAt, + Units: ctx.Units, + Timezone: ctx.Timezone, + ValidPeriod: metadata.ValidPeriod, + SourceLocationID: sourceLocationID, + SourceLocation: sourceLocation, + Sources: sourceMetadata(ctx.Bundle), + SourceWarnings: sourceWarnings(ctx.Bundle), + } +} + +func Save(path string, pkg Package) error { + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return fmt.Errorf("marshal briefing package: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temporary briefing file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temporary briefing file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary briefing file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("save briefing %q: %w", path, err) + } + return nil +} + +func sourceLocation(bundle *forecast.Bundle) (string, string) { + if bundle == nil { + return "", "" + } + for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { + if run == nil { + continue + } + if run.LocationID != "" || run.LocationName != "" { + return run.LocationID, run.LocationName + } + } + return "", "" +} + +func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { + if bundle == nil { + return nil + } + out := make([]SourceMetadata, 0, len(bundle.Sources)) + for _, source := range bundle.Sources { + out = append(out, SourceMetadata{ + Name: source.Name, + Endpoint: source.Endpoint, + FetchedAt: source.FetchedAt, + IssuedAt: source.IssuedAt, + UpdatedAt: source.UpdatedAt, + DataSHA256: source.DataSHA256, + Missing: source.Missing, + Warnings: source.Warnings, + }) + } + return out +} + +func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { + if bundle == nil { + return nil + } + return bundle.Warnings +} + +func variantForReport(id report.ID) string { + switch id { + case report.DailyToday: + return "today" + case report.DailyTomorrow: + return "tomorrow" + default: + return "" + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ad93519..08750e4 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,6 +3,10 @@ package cli import ( "bytes" "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -67,6 +71,35 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { } } +func TestRunGenerateDailyWritesBriefing(t *testing.T) { + server := dailyServer(t) + configPath := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: "+server.URL+"/\n timezone: America/Chicago\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + outPath := filepath.Join(t.TempDir(), "daily.briefing.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "generate", "daily", + "--config", configPath, + "--date", "2026-05-29", + "--out", outPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read briefing: %v", err) + } + if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily_today"`) { + t.Fatalf("briefing output missing expected content:\n%s", string(data)) + } +} + func TestResolveGenerateCommands(t *testing.T) { runner := Runner{Clock: fixedClock()} tests := []struct { @@ -190,3 +223,27 @@ func TestResolveRunRejectsOutputFlag(t *testing.T) { func fixedClock() timeutil.Clock { return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} } + +func dailyServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/observations": + _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) + case "/conditions/current": + _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) + case "/forecast/hourly": + _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) + case "/forecast/narrative": + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`)) + case "/alerts/active": + _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) + case "/discussion": + _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +}