Add Daily briefing JSON generation
This commit is contained in:
@@ -3,9 +3,9 @@
|
|||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data.
|
||||||
|
|
||||||
The application currently resolves configuration and CLI requests. Weather API
|
The application can currently produce a Daily briefing JSON artifact. Rendered
|
||||||
fetching, report generation, and `scriptorium` rendering are tracked in the
|
reports and `scriptorium` execution are tracked in the roadmap and are not
|
||||||
roadmap and are not implemented yet.
|
implemented yet.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
|
|||||||
18
docs/cli.md
18
docs/cli.md
@@ -1,8 +1,8 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` currently resolves configuration, command requests, report
|
`weatherreporter generate daily` currently writes a Daily briefing JSON artifact.
|
||||||
definitions, and valid periods, then returns a not-implemented error for report
|
Other report generation and scheduled runs still resolve configuration, report
|
||||||
generation and scheduled runs.
|
definitions, and valid periods, then return a not-implemented error.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ generation and scheduled runs.
|
|||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The command parses flags, loads configuration, resolves report identity and the
|
The command parses flags, loads configuration, fetches weather data, builds a
|
||||||
valid period, and then stops before weather data fetching or report rendering.
|
Daily briefing, and writes the JSON artifact to `--out`.
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate` commands resolve one report request. `run` commands resolve a
|
`generate daily` writes a briefing JSON artifact. Other `generate` commands
|
||||||
scheduled batch request. All report and batch execution currently returns
|
resolve one report request and stop before report generation. `run` commands
|
||||||
`not implemented` after request resolution.
|
resolve a scheduled batch request and stop before execution.
|
||||||
|
|
||||||
## Flags
|
## 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`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--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.
|
- `--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`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
|
|||||||
59
docs/internal/briefing.md
Normal file
59
docs/internal/briefing.md
Normal file
@@ -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.
|
||||||
@@ -4,9 +4,11 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/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/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -49,9 +51,28 @@ type FetchBundleRequest struct {
|
|||||||
OutputPath string
|
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 {
|
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||||
_ = ctx
|
resolved, err := ResolveGenerate(req, time.Now())
|
||||||
if _, err := ResolveGenerate(req, time.Now()); err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("generate is not implemented")
|
return fmt.Errorf("generate is not implemented")
|
||||||
@@ -151,3 +172,53 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
|||||||
}
|
}
|
||||||
return bundle, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
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) {
|
func TestResolveGenerateStorm(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
|||||||
278
internal/briefing/daily.go
Normal file
278
internal/briefing/daily.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
198
internal/briefing/daily_test.go
Normal file
198
internal/briefing/daily_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
156
internal/briefing/package.go
Normal file
156
internal/briefing/package.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestResolveGenerateCommands(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -190,3 +223,27 @@ func TestResolveRunRejectsOutputFlag(t *testing.T) {
|
|||||||
func fixedClock() timeutil.Clock {
|
func fixedClock() timeutil.Clock {
|
||||||
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user