Wire hourly generated text rendering

This commit is contained in:
2026-06-14 05:01:26 +00:00
parent 2a4ce64d6f
commit 422430613c
7 changed files with 428 additions and 75 deletions

View File

@@ -17,9 +17,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -89,21 +91,26 @@ type ReportRequest struct {
}
type ReportResult struct {
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
Notification *NotificationResult
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
}
type BatchResult struct {
@@ -151,6 +158,7 @@ func (e BatchError) Error() string {
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
}
type Notifier interface {
@@ -438,9 +446,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not implemented for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
@@ -488,11 +493,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
@@ -540,6 +549,28 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, renderErr
}
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
@@ -598,6 +629,107 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil
}
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if runErr != nil {
return nil, runErr
}
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if err != nil {
return nil, err
}
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
if err != nil {
return nil, err
}
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, hourlyText)
if err != nil {
return nil, err
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
rendered, err := reporttemplate.Render(req.Resolved.Definition.TemplateID, renderContext)
if err != nil {
return nil, err
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
req.metadata.RenderedReportPath = reportPath
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: reportPath,
Metadata: req.metadata,
MetadataPath: metadataPath,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
StructuredRunResult: structuredResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
}, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier)
if !enabled {
@@ -941,3 +1073,21 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode,
}
}
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) {
switch definition.GeneratedTextSchemaID {
case "hourly":
return generatedtext.ValidateHourly(data)
default:
return generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
}
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, hourly generatedtext.Hourly) (any, error) {
switch definition.TemplateID {
case "hourly":
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly)
default:
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
}

View File

@@ -349,9 +349,15 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
}
}
func TestGenerateHourlyReportModeNotImplemented(t *testing.T) {
func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
server := hourlyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportHourly,
@@ -362,14 +368,136 @@ func TestGenerateHourlyReportModeNotImplemented(t *testing.T) {
if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := &recordingStore{Store: filesystemStore}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stdout: `{"prepared":true}`,
ExitCode: 0,
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
structuredRunBody: `{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"confidence": "Medium"
}`,
}
_, err = GenerateReport(context.Background(), ReportRequest{
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Hourly Report\n"),
Renderer: renderer,
Store: store,
})
if err == nil || !strings.Contains(err.Error(), `generation mode "generated_text_template" is not implemented for report "hourly"`) {
t.Fatalf("GenerateReport() error = %v, want generation mode not implemented", err)
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,render_context,prepare_report,metadata"; got != want {
t.Fatalf("store calls = %v, want %s", store.calls, want)
}
assertPathsExist(t,
result.ModuleSnapshotPath,
result.DataPackagePath,
result.PreflightPath,
result.GeneratedTextRawPath,
result.GeneratedTextResultPath,
result.GeneratedTextPath,
result.RenderContextPath,
result.ReportPath,
result.MetadataPath,
)
raw, err := os.ReadFile(result.GeneratedTextRawPath)
if err != nil {
t.Fatalf("read raw generated text: %v", err)
}
if !strings.Contains(string(raw), `"summary": " Storm chances increase through late morning. "`) {
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
}
normalized, err := os.ReadFile(result.GeneratedTextPath)
if err != nil {
t.Fatalf("read validated generated text: %v", err)
}
if string(normalized) != `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}` {
t.Fatalf("validated generated text = %s, want normalized JSON", normalized)
}
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) || !strings.Contains(string(renderContext), `"LocationName": "Brentwood, MO"`) {
t.Fatalf("render context missing deterministic fields:\n%s", string(renderContext))
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
reportText := string(reportData)
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Flood Watch: Flooding possible (Moderate)",
"Short-term AFD narrative for hourly report.",
"Hourly Storm Chances - Scattered showers and thunderstorms are possible.",
} {
if !strings.Contains(reportText, want) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText)
}
}
if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
}
if result.StructuredRunResult == nil || result.StructuredRunResult.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("StructuredRunResult = %#v, want captured structured run result", result.StructuredRunResult)
}
if result.RunResult != nil {
t.Fatalf("RunResult = %#v, want nil for generated-text template workflow", result.RunResult)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
}
if result.Metadata.GeneratedTextSchemaID != "hourly" ||
result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath ||
result.Metadata.GeneratedTextResultPath != result.GeneratedTextResultPath ||
result.Metadata.GeneratedTextPath != result.GeneratedTextPath ||
result.Metadata.RenderContextPath != result.RenderContextPath ||
result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata generated-text links = %#v, want saved artifact links", result.Metadata)
}
metadataData, err := os.ReadFile(result.MetadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if !strings.Contains(string(metadataData), `"generatedTextSchemaId": "hourly"`) ||
!strings.Contains(string(metadataData), result.GeneratedTextRawPath) ||
!strings.Contains(string(metadataData), result.RenderContextPath) {
t.Fatalf("metadata JSON missing generated-text links:\n%s", string(metadataData))
}
}
@@ -1715,15 +1843,20 @@ func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
}
type recordingRenderer struct {
renderCalls int
runCalls int
renderRequest scriptorium.RenderRequest
runRequest scriptorium.RunRequest
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
err error
runErr error
runBody string
renderCalls int
runCalls int
structuredRunCalls int
renderRequest scriptorium.RenderRequest
runRequest scriptorium.RunRequest
structuredRunRequest scriptorium.StructuredRunRequest
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
err error
runErr error
structuredRunErr error
runBody string
structuredRunBody string
}
type recordingStore struct {
@@ -1746,6 +1879,26 @@ func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Reso
return s.Store.SavePreflight(ctx, resolved, artifact)
}
func (s *recordingStore) SaveGeneratedTextRaw(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text_raw")
return s.Store.SaveGeneratedTextRaw(ctx, resolved, data)
}
func (s *recordingStore) SaveGeneratedTextResult(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "generated_text_result")
return s.Store.SaveGeneratedTextResult(ctx, resolved, value)
}
func (s *recordingStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text")
return s.Store.SaveGeneratedText(ctx, resolved, data)
}
func (s *recordingStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "render_context")
return s.Store.SaveRenderContext(ctx, resolved, value)
}
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
s.calls = append(s.calls, "prepare_report")
return s.Store.PrepareRenderedReport(ctx, resolved)
@@ -1765,10 +1918,11 @@ func successfulRenderer(body string) *recordingRenderer {
}
type selectiveRenderer struct {
renderCalls int
runCalls int
failRenderPrompt string
runBody string
renderCalls int
runCalls int
structuredRunCalls int
failRenderPrompt string
runBody string
}
type recordingNotifier struct {
@@ -1826,6 +1980,11 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++
r.renderRequest = req
@@ -1845,3 +2004,17 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
}
return r.runResult, r.runErr
}
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
r.structuredRunRequest = req
if r.structuredRunBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil {
return nil, err
}
}
if r.structuredRunResult != nil {
r.structuredRunResult.OutputPath = req.OutputPath
}
return r.structuredRunResult, r.structuredRunErr
}

View File

@@ -37,7 +37,7 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate commands.
--out PATH Write an extra Markdown report copy where supported by the generate command.
--out-dir PATH Write extra Markdown report copies for run commands.
`