Add Daily prompt input preflight
This commit is contained in:
140
internal/promptinput/package.go
Normal file
140
internal/promptinput/package.go
Normal 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
|
||||
}
|
||||
94
internal/promptinput/package_test.go
Normal file
94
internal/promptinput/package_test.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user