Add Daily prompt input preflight

This commit is contained in:
2026-05-29 17:32:57 +00:00
parent cf8e1de3ff
commit e556ca5edc
12 changed files with 868 additions and 26 deletions

View File

@@ -0,0 +1,140 @@
// Package promptinput builds prompt data packages from briefing packages.
package promptinput
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const SchemaVersion = "weatherreporter.data_package.v1"
type Package struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
Report Report `json:"report"`
Briefing briefing.Package `json:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
}
type Report struct {
ID report.ID `json:"id"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
}
type RecentChanges struct {
Items []Change `json:"items"`
}
type Change struct {
Type string `json:"type"`
Message string `json:"message"`
}
func Build(briefingPackage briefing.Package) (Package, error) {
pkg := Package{
SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID,
Report: Report{
ID: briefingPackage.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone,
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
},
Briefing: briefingPackage,
RecentChanges: RecentChanges{Items: []Change{}},
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
}
if err := Validate(pkg); err != nil {
return Package{}, err
}
return pkg, nil
}
func Validate(pkg Package) error {
if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required")
}
if pkg.RunID == "" {
return fmt.Errorf("runId is required")
}
if pkg.Report.ID == "" {
return fmt.Errorf("report.id is required")
}
if pkg.Report.PromptID == "" {
return fmt.Errorf("report.promptId is required")
}
if pkg.Report.GeneratedAt.IsZero() {
return fmt.Errorf("report.generatedAt is required")
}
if pkg.Report.Timezone == "" {
return fmt.Errorf("report.timezone is required")
}
if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid")
}
if pkg.Briefing.Metadata.RunID == "" {
return fmt.Errorf("briefing.metadata.runId is required")
}
if pkg.Briefing.Metadata.RunID != pkg.RunID {
return fmt.Errorf("briefing.metadata.runId must match runId")
}
if pkg.Briefing.Metadata.SchemaVersion == "" {
return fmt.Errorf("briefing.metadata.schemaVersion is required")
}
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID {
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
}
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
}
if pkg.Briefing.Daily == nil {
return fmt.Errorf("briefing.daily is required")
}
return nil
}
func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil {
return err
}
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package 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 data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,94 @@
package promptinput
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildDailyDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
}
if pkg.RunID != "20260529T100000Z_daily_today" {
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID)
}
if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
}
if pkg.Briefing.Daily == nil {
t.Fatal("Briefing.Daily = nil")
}
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
}
}
func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
pkg.RunID = ""
err = Validate(pkg)
if err == nil {
t.Fatal("Validate() error = nil, want required field error")
}
if !strings.Contains(err.Error(), "runId") {
t.Fatalf("error = %q, want runId context", err.Error())
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
first, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
t.Fatalf("first marshal: %v", err)
}
second, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
t.Fatalf("second marshal: %v", err)
}
if string(first) != string(second) {
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second))
}
}
func validBriefingPackage() briefing.Package {
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: "20260529T100000Z_daily_today",
ReportID: report.DailyToday,
PromptID: "weather.daily_report",
GeneratedAt: generatedAt,
Units: "us",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
},
},
Daily: &briefing.Daily{
ForecastSummaryDate: "2026-05-29",
},
}
}