From 422430613caab1f0d0a2afcd8eb16a1e06b95481 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 14 Jun 2026 05:01:26 +0000 Subject: [PATCH] Wire hourly generated text rendering --- docs/cli.md | 15 ++- docs/internal/app-orchestration.md | 32 +++-- docs/internal/report-registry.md | 9 +- docs/operations.md | 40 +++--- internal/app/app.go | 196 +++++++++++++++++++++++---- internal/app/app_test.go | 209 ++++++++++++++++++++++++++--- internal/cli/root.go | 2 +- 7 files changed, 428 insertions(+), 75 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 84cb66d..3f45a27 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -38,11 +38,13 @@ weatherreporter inspect sources [--config PATH] RUN_ID Implemented Markdown `generate` commands write a JSON module snapshot, YAML data package, preflight artifact, managed Markdown report, and metadata under the configured workspace. `--out` writes an extra Markdown copy for the -operator; distributor notification uses the managed report path, not the extra -copy. `generate hourly` is parsed and resolved, covers the next six hours in -the effective report timezone, and does not accept date or event window flags; -its rendering workflow is not implemented yet. `generate storm` requires -explicit event-window bounds with `--start` and `--end`. +operator on those Markdown-path commands; distributor notification uses the +managed report path, not the extra copy. `generate hourly` covers the next six +hours in the effective report timezone, does not accept date or event window +flags, writes managed generated-text artifacts, validates the structured text, +and renders the managed Markdown report from the embedded hourly template. +`generate storm` requires explicit event-window bounds with `--start` and +`--end`. `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch @@ -66,7 +68,7 @@ They do not fetch weather data or invoke `scriptorium`. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--units VALUE`: override configured Weather API units for `generate` and `run`. - `--tz NAME`: override configured Weather API timezone for `generate` and `run`. -- `--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 morning` and `run evening`. - `--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`. @@ -83,6 +85,7 @@ are no distributor-specific CLI flags. ```sh weatherreporter generate tomorrow --out ./tomorrow.md +weatherreporter generate hourly weatherreporter generate three-day --out ./three-day.md weatherreporter generate weekend --out ./weekend.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index eb048ff..57d8751 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -28,7 +28,8 @@ Outputs: - generated report results with JSON module snapshot, YAML data package, preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium - result details, and notification result when attempted + result details, generated-text artifact paths when applicable, and + notification result when attempted - batch summaries with per-report status, artifact paths, error text, and notification outcome when attempted - saved Weather API bundle JSON for fetch workflows @@ -63,7 +64,7 @@ defaults. ## Generation Workflow -Single-report generation follows this order: +Single-report generation shares this setup: 1. Resolve the command report to a `report.Resolved` value. 2. Create or use a filesystem store. @@ -76,13 +77,26 @@ Single-report generation follows this order: 9. Run Scriptorium render preflight. 10. Save preflight JSON when a render result is available. 11. Save metadata for inspection. -12. Run Scriptorium report generation to the managed report path. -13. Copy the managed report to the requested `--out` path when provided. -14. Save metadata with the managed report path. -15. If distributor notification is enabled, notify using the managed report + +For `scriptorium_markdown` reports, generation then: + +12. Runs Scriptorium report generation to the managed report path. +13. Copies the managed report to the requested `--out` path when provided. +14. Saves metadata with the managed report path. +15. If distributor notification is enabled, notifies using the managed report path as the source file. -16. Save a distributor notification debug artifact and update metadata with its - path. +16. Saves a distributor notification debug artifact and updates metadata with + its path. + +For `generated_text_template` reports, generation then: + +12. Runs structured Scriptorium generation to the raw generated-text JSON path. +13. Saves the structured Scriptorium run result. +14. Validates and saves normalized generated text. +15. Builds and saves a typed render context. +16. Renders Markdown from the embedded template to the managed report path. +17. Saves final metadata with generated-text paths, render context path, schema + ID, and managed report path. If render preflight returns both a result and an error, preflight JSON and metadata are persisted before the error is returned. If Scriptorium report @@ -142,6 +156,8 @@ Inspect: - Generated reports use the same app request and result types regardless of report ID. - Render preflight precedes Scriptorium report generation. +- Generated-text reports render Markdown from a curated render context, not from + a raw data package. - Recent Changes are computed from structured module snapshots. - Metadata links artifacts produced for a run. - Distributor notification maps the managed Markdown report path to configured diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index e5fa79b..86689d4 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -26,10 +26,11 @@ Each report definition declares: - morning or evening batch membership - default ordered module composition -Current Markdown report definitions use the `scriptorium_markdown` generation -mode. Their template and structured-text schema identifiers are empty. Hourly -Report declares `generated_text_template` with template ID `hourly` and schema -ID `hourly`; the app rendering workflow for that mode is not implemented yet. +Markdown report definitions use the `scriptorium_markdown` generation mode. +Their template and structured-text schema identifiers are empty. Hourly Report +declares `generated_text_template` with template ID `hourly` and schema ID +`hourly`; the app uses those identifiers to validate generated text and render +the embedded hourly template. ## Reports diff --git a/docs/operations.md b/docs/operations.md index ddd4bdd..dfd5986 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -11,24 +11,27 @@ Generation commands: ```text weatherreporter generate daily --date 2026-05-29 weatherreporter generate tomorrow +weatherreporter generate hourly weatherreporter generate three-day weatherreporter generate weekend weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 ``` -Each implemented Markdown generation command resolves a report period, fetches -a Weather API bundle, builds a JSON module snapshot, builds a YAML prompt input -data package, runs `scriptorium render`, runs `scriptorium run`, and writes -managed artifacts under the configured workspace. -When distributor notification is enabled, weatherreporter uploads the managed -Markdown report after `scriptorium run` succeeds and final metadata is saved. -`--out PATH` writes an extra Markdown copy for the current generated report; it -is not used as the distributor upload source. +Markdown-path generation commands resolve a report period, fetch a Weather API +bundle, build a JSON module snapshot, build a YAML prompt input data package, +run `scriptorium render`, run `scriptorium run`, and write managed artifacts +under the configured workspace. When distributor notification is enabled, +weatherreporter uploads the managed Markdown report after `scriptorium run` +succeeds and final metadata is saved. `--out PATH` writes an extra Markdown +copy for Markdown-path generated reports; it is not used as the distributor +upload source. -`generate hourly` is an explicit command shell. It covers the six-hour rolling -period from generation time in the effective report timezone and is not part of -scheduled morning or evening batches, but its rendering workflow is not -implemented yet. +`generate hourly` covers the six-hour rolling period from generation time in +the effective report timezone and is not part of scheduled morning or evening +batches. It builds the same module snapshot and data package, runs +`scriptorium render` as preflight, runs structured `scriptorium run` to raw +GeneratedText JSON, validates the structured text, saves a render context, and +renders the managed Markdown report from the embedded hourly template. Batch commands: @@ -71,6 +74,10 @@ workspace/ YYYY-MM-DD/ .modules.json .metadata.json + .generated_text.raw.json + .generated_text.run.json + .generated_text.json + .render_context.json storm/ YYYY-MM-DD/ .modules.json @@ -156,6 +163,7 @@ Each generated report writes metadata that links: - prompt input data package path - preflight output path - managed Markdown report path +- generated text schema ID and generated-text artifact paths for Hourly Report - distributor notification debug artifact path, when notification is attempted Batch summaries include report status, error text when applicable, notification @@ -185,9 +193,11 @@ report generation has a distinct retry identity. The default bundle path uses the valid-period start date, artifact group, and RunID. Distributor owns destination merge, retention, and derived snapshot behavior such as `latest`. -Notification happens after final metadata save. Weather API, module snapshot, -data-package, render preflight, Scriptorium run, and metadata-save failures do -not trigger notification. A notification failure fails that report. +Notification happens after final metadata save for Markdown-path generation. +Weather API, module snapshot, data-package, render preflight, Scriptorium run, +and metadata-save failures do not trigger notification. Hourly generated-text +reports write the managed Markdown report and generated-text artifacts but do +not notify distributor yet. A notification failure fails that report. In a batch, other reports continue, the failed report includes notification fields in the JSON summary, and the batch returns nonzero. diff --git a/internal/app/app.go b/internal/app/app.go index 06ff124..8d78192 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) + } +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6b11302..c1d4325 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 911f7ae..7b954ca 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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. `