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

@@ -38,11 +38,13 @@ weatherreporter inspect sources [--config PATH] RUN_ID
Implemented Markdown `generate` commands write a JSON module snapshot, YAML Implemented Markdown `generate` commands write a JSON module snapshot, YAML
data package, preflight artifact, managed Markdown report, and metadata under data package, preflight artifact, managed Markdown report, and metadata under
the configured workspace. `--out` writes an extra Markdown copy for the the configured workspace. `--out` writes an extra Markdown copy for the
operator; distributor notification uses the managed report path, not the extra operator on those Markdown-path commands; distributor notification uses the
copy. `generate hourly` is parsed and resolved, covers the next six hours in managed report path, not the extra copy. `generate hourly` covers the next six
the effective report timezone, and does not accept date or event window flags; hours in the effective report timezone, does not accept date or event window
its rendering workflow is not implemented yet. `generate storm` requires flags, writes managed generated-text artifacts, validates the structured text,
explicit event-window bounds with `--start` and `--end`. 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 `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch 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`. - `--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`. - `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone 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`. - `--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. - `--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`. - `--start TIME`: required start time for `generate storm`.
@@ -83,6 +85,7 @@ are no distributor-specific CLI flags.
```sh ```sh
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md

View File

@@ -28,7 +28,8 @@ Outputs:
- generated report results with JSON module snapshot, YAML data package, - generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium 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 - batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted notification outcome when attempted
- saved Weather API bundle JSON for fetch workflows - saved Weather API bundle JSON for fetch workflows
@@ -63,7 +64,7 @@ defaults.
## Generation Workflow ## Generation Workflow
Single-report generation follows this order: Single-report generation shares this setup:
1. Resolve the command report to a `report.Resolved` value. 1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store. 2. Create or use a filesystem store.
@@ -76,13 +77,26 @@ Single-report generation follows this order:
9. Run Scriptorium render preflight. 9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available. 10. Save preflight JSON when a render result is available.
11. Save metadata for inspection. 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. For `scriptorium_markdown` reports, generation then:
14. Save metadata with the managed report path.
15. If distributor notification is enabled, notify using the managed report 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. path as the source file.
16. Save a distributor notification debug artifact and update metadata with its 16. Saves a distributor notification debug artifact and updates metadata with
path. 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 If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report 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 - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - 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. - Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured - Distributor notification maps the managed Markdown report path to configured

View File

@@ -26,10 +26,11 @@ Each report definition declares:
- morning or evening batch membership - morning or evening batch membership
- default ordered module composition - default ordered module composition
Current Markdown report definitions use the `scriptorium_markdown` generation Markdown report definitions use the `scriptorium_markdown` generation mode.
mode. Their template and structured-text schema identifiers are empty. Hourly Their template and structured-text schema identifiers are empty. Hourly Report
Report declares `generated_text_template` with template ID `hourly` and schema declares `generated_text_template` with template ID `hourly` and schema ID
ID `hourly`; the app rendering workflow for that mode is not implemented yet. `hourly`; the app uses those identifiers to validate generated text and render
the embedded hourly template.
## Reports ## Reports

View File

@@ -11,24 +11,27 @@ Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day weatherreporter generate three-day
weatherreporter generate weekend weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
``` ```
Each implemented Markdown generation command resolves a report period, fetches Markdown-path generation commands resolve a report period, fetch a Weather API
a Weather API bundle, builds a JSON module snapshot, builds a YAML prompt input bundle, build a JSON module snapshot, build a YAML prompt input data package,
data package, runs `scriptorium render`, runs `scriptorium run`, and writes run `scriptorium render`, run `scriptorium run`, and write managed artifacts
managed artifacts under the configured workspace. under the configured workspace. When distributor notification is enabled,
When distributor notification is enabled, weatherreporter uploads the managed weatherreporter uploads the managed Markdown report after `scriptorium run`
Markdown report after `scriptorium run` succeeds and final metadata is saved. succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
`--out PATH` writes an extra Markdown copy for the current generated report; it copy for Markdown-path generated reports; it is not used as the distributor
is not used as the distributor upload source. upload source.
`generate hourly` is an explicit command shell. It covers the six-hour rolling `generate hourly` covers the six-hour rolling period from generation time in
period from generation time in the effective report timezone and is not part of the effective report timezone and is not part of scheduled morning or evening
scheduled morning or evening batches, but its rendering workflow is not batches. It builds the same module snapshot and data package, runs
implemented yet. `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: Batch commands:
@@ -71,6 +74,10 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -156,6 +163,7 @@ Each generated report writes metadata that links:
- prompt input data package path - prompt input data package path
- preflight output path - preflight output path
- managed Markdown report 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 - distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification 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 the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`. destination merge, retention, and derived snapshot behavior such as `latest`.
Notification happens after final metadata save. Weather API, module snapshot, Notification happens after final metadata save for Markdown-path generation.
data-package, render preflight, Scriptorium run, and metadata-save failures do Weather API, module snapshot, data-package, render preflight, Scriptorium run,
not trigger notification. A notification failure fails that report. 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 In a batch, other reports continue, the failed report includes notification
fields in the JSON summary, and the batch returns nonzero. fields in the JSON summary, and the batch returns nonzero.

View File

@@ -17,9 +17,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "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/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "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/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -89,21 +91,26 @@ type ReportRequest struct {
} }
type ReportResult struct { type ReportResult struct {
ModuleSnapshot module.Snapshot ModuleSnapshot module.Snapshot
ModuleSnapshotPath string ModuleSnapshotPath string
DataPackage promptinput.Package DataPackage promptinput.Package
DataPackagePath string DataPackagePath string
PreflightPath string PreflightPath string
ReportPath string ReportPath string
OutputPath string OutputPath string
NotificationPath string NotificationPath string
Metadata state.Metadata Metadata state.Metadata
MetadataPath string MetadataPath string
PriorSnapshot *state.PriorSnapshot PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
Notification *NotificationResult StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
} }
type BatchResult struct { type BatchResult struct {
@@ -151,6 +158,7 @@ func (e BatchError) Error() string {
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
} }
type Notifier interface { type Notifier interface {
@@ -438,9 +446,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
} }
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { 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 store := req.Store
if store == nil { if store == nil {
defaultStore, err := defaultStore(req.Config) 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)) briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{ metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath, ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata, Metadata: paths.Metadata,
DataPackage: paths.DataPackage, DataPackage: paths.DataPackage,
Preflight: paths.Preflight, Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport, RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
}) })
dataPackage, err := promptinput.Build(promptinput.BuildRequest{ dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata), Metadata: promptMetadata(metadata),
@@ -540,6 +549,28 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, renderErr 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) reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -598,6 +629,107 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil }, 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) { 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) notifier, enabled := reportNotifier(cfg, notifier)
if !enabled { if !enabled {
@@ -941,3 +1073,21 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode, 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 := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportHourly, Report: ReportHourly,
@@ -362,14 +368,136 @@ func TestGenerateHourlyReportModeNotImplemented(t *testing.T) {
if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate { if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode) 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, Config: cfg,
Resolved: resolved, 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"`) { if err != nil {
t.Fatalf("GenerateReport() error = %v, want generation mode not implemented", err) 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 { type recordingRenderer struct {
renderCalls int renderCalls int
runCalls int runCalls int
renderRequest scriptorium.RenderRequest structuredRunCalls int
runRequest scriptorium.RunRequest renderRequest scriptorium.RenderRequest
renderResult *scriptorium.RenderResult runRequest scriptorium.RunRequest
runResult *scriptorium.RunResult structuredRunRequest scriptorium.StructuredRunRequest
err error renderResult *scriptorium.RenderResult
runErr error runResult *scriptorium.RunResult
runBody string structuredRunResult *scriptorium.StructuredRunResult
err error
runErr error
structuredRunErr error
runBody string
structuredRunBody string
} }
type recordingStore struct { type recordingStore struct {
@@ -1746,6 +1879,26 @@ func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Reso
return s.Store.SavePreflight(ctx, resolved, artifact) 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) { func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
s.calls = append(s.calls, "prepare_report") s.calls = append(s.calls, "prepare_report")
return s.Store.PrepareRenderedReport(ctx, resolved) return s.Store.PrepareRenderedReport(ctx, resolved)
@@ -1765,10 +1918,11 @@ func successfulRenderer(body string) *recordingRenderer {
} }
type selectiveRenderer struct { type selectiveRenderer struct {
renderCalls int renderCalls int
runCalls int runCalls int
failRenderPrompt string structuredRunCalls int
runBody string failRenderPrompt string
runBody string
} }
type recordingNotifier struct { 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 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) { func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++ r.renderCalls++
r.renderRequest = req r.renderRequest = req
@@ -1845,3 +2004,17 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
} }
return r.runResult, r.runErr 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. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --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. --out-dir PATH Write extra Markdown report copies for run commands.
` `