Add Daily briefing JSON generation

This commit is contained in:
2026-05-29 17:23:49 +00:00
parent caf21dfedd
commit cf8e1de3ff
9 changed files with 922 additions and 14 deletions

View File

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

View File

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

59
docs/internal/briefing.md Normal file
View 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.

View File

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

View File

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

278
internal/briefing/daily.go Normal file
View 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
}

View 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
}

View 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 ""
}
}

View File

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