Use Promptkit for single report generation

This commit is contained in:
2026-07-31 04:41:02 +00:00
parent 9a17a8de93
commit 06b26d5e88
14 changed files with 772 additions and 281 deletions

View File

@@ -80,7 +80,7 @@ time, valid period, and status:
``` ```
When available, the summary also includes `reportPath`, `metadataPath`, When available, the summary also includes `reportPath`, `metadataPath`,
`dataPackagePath`, `preflightPath`, `generatedTextRawPath`, `generatedTextResultPath`, `dataPackagePath`, `preparationPath`, `executionPath`, `generatedTextRawPath`,
`generatedTextPath`, and `renderContextPath`. `outputPath` is included only `generatedTextPath`, and `renderContextPath`. `outputPath` is included only
when `--out` wrote an extra copy. Distributor notification, when attempted, when `--out` wrote an extra copy. Distributor notification, when attempted,
adds `notificationPath` and may add a compact `notification` object. adds `notificationPath` and may add a compact `notification` object.

View File

@@ -147,9 +147,9 @@ source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
### `promptkit` ### `promptkit`
Promptkit configuration prepares the local executor and prompt/profile checks. Promptkit configuration selects the executor and prompt/profile checks for
Scriptorium remains the active generator until the Promptkit execution workflow every `generate` command. Scriptorium settings remain in use by batch commands
is enabled. while their compatibility workflow remains active.
| Field | Default | Rules | | Field | Default | Rules |
| --- | --- | --- | | --- | --- | --- |

View File

@@ -5,8 +5,8 @@ Weatherreporter. It provides a concise repository orientation and routes each
kind of change to its canonical documentation. kind of change to its canonical documentation.
Weatherreporter is a Go CLI that collects normalized weather data, derives Weatherreporter is a Go CLI that collects normalized weather data, derives
deterministic report facts and module snapshots, invokes Scriptorium for deterministic report facts and module snapshots, executes Promptkit for
generated text, renders managed Markdown reports, and can upload completed single-report generated text, renders managed Markdown reports, and can upload completed
reports through Distributor. Start with the [README](../README.md) for product reports through Distributor. Start with the [README](../README.md) for product
context and the [architecture policy](policy/architecture.md) for system context and the [architecture policy](policy/architecture.md) for system
boundaries and invariants. boundaries and invariants.
@@ -27,7 +27,7 @@ boundaries and invariants.
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. | | Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. | | Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
| Recent Changes comparison | [Changes internals](internal/changes.md) and [operations guide](operations.md) | The internal guide owns structured comparison; operations owns user-visible artifact behavior. | | Recent Changes comparison | [Changes internals](internal/changes.md) and [operations guide](operations.md) | The internal guide owns structured comparison; operations owns user-visible artifact behavior. |
| Scriptorium commands, subprocess execution, prompt inputs, or result handling | [Scriptorium integration](integrations/scriptorium.md), [Scriptorium adapter internals](internal/scriptorium-adapter.md), and [prompt-input internals](internal/prompt-input.md) | These separate the external CLI contract, subprocess boundary, and input construction. | | Prompt execution, profiles, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. | | Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
| Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. | | Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. |
| Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. | | Distributor bundles, uploads, notification artifacts, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
@@ -46,7 +46,7 @@ present before introducing a new package or abstraction.
| `internal/cli` | Command parsing, flags, help, output, and command wiring. | | `internal/cli` | Command parsing, flags, help, output, and command wiring. |
| `internal/app` | Generation, batches, collection coordination, notification, and inspection orchestration. | | `internal/app` | Generation, batches, collection coordination, notification, and inspection orchestration. |
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. | | `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
| `internal/adapters` | Weather API, Scriptorium, and Distributor boundaries. | | `internal/adapters` | Weather API, Promptkit, Scriptorium batch compatibility, and Distributor boundaries. |
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. | | `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
| `internal/report`, `internal/module`, `internal/briefing`, `internal/changes` | Report registry, module contracts and values, and structured comparison. | | `internal/report`, `internal/module`, `internal/briefing`, `internal/changes` | Report registry, module contracts and values, and structured comparison. |
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. | | `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |

View File

@@ -8,12 +8,12 @@ application-facing interfaces used for tests.
## Inputs And Outputs ## Inputs And Outputs
The package accepts generate, resolved-report, batch, explicit-collection, and The package accepts generate, resolved-report, batch, explicit-collection, and
inspection requests. Generation and batch requests may supply collector, inspection requests. Generation and batch requests may supply collaborators for
renderer, store, and notifier implementations for tests; production defaults tests; single-report generation uses a Promptkit executor, while batch requests
use the focused packages. retain a renderer for their compatibility workflow.
A report result contains the module snapshot, prompt package, Scriptorium A report result contains the module snapshot, prompt package, prompt
results, generated-text artifacts, report and metadata provenance, generated-text artifacts, report and metadata
paths, prior snapshot, Recent Changes, and notification information. A batch paths, prior snapshot, Recent Changes, and notification information. A batch
result contains aggregate counts, per-report outcomes, and an optional batch result contains aggregate counts, per-report outcomes, and an optional batch
notification. Inspection returns persisted values only. notification. Inspection returns persisted values only.
@@ -31,45 +31,47 @@ safe project-owned identity and provenance values.
## Single-Report Workflow ## Single-Report Workflow
`GenerateDetailed` first collects weather data, then resolves the requested `GenerateDetailed` resolves the requested report using the configured registry
report using the configured registry and current time, and finally calls and current time, verifies the exact Promptkit prompt and selected profile, and
`GenerateReport` with that explicit collection. It returns no result when only then collects weather data. Inspection failure produces no collection or
collection or resolution fails. managed artifacts.
`GenerateReport` requires a non-nil normalized bundle and then performs this Single-report generation requires a non-nil normalized bundle and then performs this
ordered work: ordered work:
1. Select a state store, determine artifact destinations, and locate a prior 1. Select a state store, determine artifact destinations, and locate a prior
compatible snapshot. compatible snapshot.
2. Build report facts and deterministic module snapshots, then save the module 2. Build report facts and deterministic module snapshots, then save the module
snapshot and calculate Recent Changes. snapshot and calculate Recent Changes.
3. Build and save the prompt data package, run Scriptorium render preflight, 3. Serialize and save the prompt data package once, then use those exact bytes
save any preflight result, and save initial metadata. for Promptkit execution.
4. Run structured Scriptorium generation, validate and save generated text, 4. Save preparation provenance and V2 metadata from the preparation callback
build and save a render context, and render the managed Markdown template. before provider execution. Save execution provenance after raw output is
5. Optionally make an output copy, save final metadata, optionally notify persisted, then save updated metadata.
5. Validate and save generated text, build and save a render context, and
render the managed Markdown template.
6. Optionally make an output copy, save final metadata, optionally notify
Distributor from the managed report path, and save metadata again when a Distributor from the managed report path, and save metadata again when a
notification path is produced. notification path is produced.
Every report looks up its catalog definition, runs structured Scriptorium Every single report looks up its catalog definition, saves raw Promptkit output,
output to the raw artifact, preserves the structured run result, validates and preserves safe preparation and execution provenance separately, validates and
saves generated text, builds and saves a render context, then renders the saves generated text, builds and saves a render context, then renders the
embedded Markdown template. Schema, template, and subprocess details remain in embedded Markdown template. Schema and template details remain in their
their [generated-text](generatedtext.md), [generated-text](generatedtext.md) and [report-template](reporttemplate.md)
[report-template](reporttemplate.md), and [Scriptorium adapter](scriptorium-adapter.md)
owners. owners.
If preflight returns a result with an error, the result and initial metadata are Preparation and operational execution failures save classified receipts and
saved before the error returns. If report generation fails after a managed path metadata before returning. A completed Promptkit validation rejection saves raw
is prepared, metadata still records that path; output copies and notification output, an execution receipt, and metadata before returning. If later report
are skipped. Generated-text failures preserve the latest artifact reached generation fails, the result retains every reached safe artifact path; output
before failure when it was saved. copies and notification are skipped until rendering succeeds.
## Batch And Inspection Workflows ## Batch And Inspection Workflows
`RunBatchDetailed` collects once, asks the report registry to plan the batch `RunBatchDetailed` collects once, asks the report registry to plan the batch
from that collection, and invokes `GenerateReport` independently for every from that collection, and invokes its isolated Scriptorium compatibility helper
planned report using the same collection and state store. Per-report independently for every planned report using the same collection and state store. Per-report
notification is suppressed. A failed report is recorded and does not prevent notification is suppressed. A failed report is recorded and does not prevent
later planned reports from running. later planned reports from running.
@@ -87,13 +89,13 @@ provenance. They neither collect data nor invoke Scriptorium or Distributor.
## Boundaries And Failure Propagation ## Boundaries And Failure Propagation
The app layer does not parse flags, load configuration files, implement Weather The app layer does not parse flags, load configuration files, implement Weather
API transport, construct Scriptorium argv, or define report registry policy. It API transport, invoke provider SDKs, or define report registry policy. It
coordinates the relevant collaborators and preserves their error context. coordinates the relevant collaborators and preserves their error context.
- Collection failure stops a single report or batch before resolution or - Prompt inspection failure stops a single report before collection or durable
planning completes. writes. Collection failure stops a single report or batch before planning.
- State, fact, module, prompt-input, or preflight failures stop that report - State, fact, module, prompt-input, preparation, or execution failures stop
before report generation. that report before later report generation.
- A terminal Distributor failure is returned with the saved notification - A terminal Distributor failure is returned with the saved notification
information when available. information when available.
- Batch failures are represented per report and through aggregate batch status. - Batch failures are represented per report and through aggregate batch status.
@@ -108,7 +110,8 @@ Focused tests are in `internal/app/app_test.go` and
- Production workflows collect through `internal/collect`. - Production workflows collect through `internal/collect`.
- A report uses one explicit normalized collection throughout its generation. - A report uses one explicit normalized collection throughout its generation.
- Render preflight precedes report generation. - Prompt preparation provenance and metadata are persisted before provider
execution.
- Recent Changes compare structured module snapshots. - Recent Changes compare structured module snapshots.
- Reports render from a validated typed context, never directly from a raw - Reports render from a validated typed context, never directly from a raw
prompt package. prompt package.

View File

@@ -14,8 +14,8 @@ batch execution to `internal/app`.
`Runner` also owns a project-owned prompt-executor factory seam. Its production `Runner` also owns a project-owned prompt-executor factory seam. Its production
factory maps `promptkit` configuration to the Promptkit adapter, while tests can factory maps `promptkit` configuration to the Promptkit adapter, while tests can
inject a factory without importing dependency types. Construction is retained as inject a factory without importing dependency types. Each `generate` request
a separate seam until the generation workflow begins using that executor. constructs one executor after configuration loads and passes it to the app.
For inspection, it loads configuration, builds the appropriate app inspection For inspection, it loads configuration, builds the appropriate app inspection
request, and writes the returned value. Inspection is read-only; the inspected request, and writes the returned value. Inspection is read-only; the inspected

View File

@@ -15,10 +15,10 @@ weatherreporter generate today --out ./today.md
``` ```
A generation collects weather data, resolves the report period, builds and A generation collects weather data, resolves the report period, builds and
persists the module snapshot and prompt data package, runs Scriptorium persists the module snapshot and prompt data package, records Promptkit
preflight, then persists generated-text artifacts, validates the structured preparation provenance before provider execution, then persists raw output and
generated text, and renders the managed Markdown report from the validated text execution provenance, validates the structured generated text, and renders the
and deterministic values. managed Markdown report from the validated text and deterministic values.
The managed report and its final metadata are saved before single-report The managed report and its final metadata are saved before single-report
Distributor notification is attempted. `--out` writes an extra operator copy; Distributor notification is attempted. `--out` writes an extra operator copy;
@@ -56,20 +56,21 @@ workspace/
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/prompt_execution.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json preflight/<artifact_group>/<YYYY-MM-DD>/prompt_preparation.<run_id>.json
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
``` ```
The generated-text and render-context artifacts are written for every report. The generated-text and render-context artifacts are written for every completed
A report's metadata links the module snapshot, data package, preflight artifact, single-report generation.
managed report, generated-text artifacts, and any available single-report A report's metadata links the module snapshot, data package, preparation and
execution receipts, managed report, generated-text artifacts, and any available single-report
notification artifact. Batch notification artifacts are separate batch-level notification artifact. Batch notification artifacts are separate batch-level
records under `notifications/batches`. records under `notifications/batches`.
@@ -128,12 +129,12 @@ RunID is an error; use `inspect reports` to obtain a valid value.
Keep the workspace when a run fails: artifacts reached before the failure Keep the workspace when a run fails: artifacts reached before the failure
remain available where they can be safely persisted. remain available where they can be safely persisted.
- A preflight failure can leave the preflight artifact and metadata. - A preparation failure can leave its classified receipt and metadata.
- A report-generation failure can leave the managed report, module snapshot, - A report-generation failure can leave the managed report, module snapshot,
data package, and metadata. data package, and metadata.
- A generated-text failure can leave raw text, the structured run result, or a - A completed prompt validation rejection leaves raw text, an execution receipt,
validated generated-text and render-context artifact, depending on where it and metadata. Later generated-text failures can also leave validated text and
stopped. a render-context artifact, depending on where they stopped.
- A single-report notification failure preserves the report and final metadata, - A single-report notification failure preserves the report and final metadata,
including its notification artifact when it was written. including its notification artifact when it was written.
- A batch notification failure preserves each report's artifacts and adds the - A batch notification failure preserves each report's artifacts and adds the

View File

@@ -19,6 +19,7 @@ import (
"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/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"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/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
@@ -50,6 +51,8 @@ type GenerateRequest struct {
Date time.Time Date time.Time
Collector Collector Collector Collector
Notifier Notifier Notifier Notifier
Executor promptexec.Executor
Store state.Store
} }
type BatchRequest struct { type BatchRequest struct {
@@ -90,28 +93,25 @@ 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
PreparationPath string PreparationPath string
ExecutionPath string ExecutionPath string
LLMDebugPath string LLMDebugPath 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 GeneratedTextRawPath string
StructuredRunResult *scriptorium.StructuredRunResult GeneratedTextPath string
GeneratedTextRawPath string RenderContextPath string
GeneratedTextResultPath string Notification *NotificationResult
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
} }
type BatchResult struct { type BatchResult struct {
@@ -275,20 +275,27 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
resolved, err := ResolveGenerate(req, now) resolved, err := ResolveGenerate(req, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return GenerateReport(ctx, ReportRequest{ inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
Config: req.Config, Resolved: resolved,
Resolved: resolved, Executor: req.Executor,
OutputPath: req.OutputPath, Promptkit: req.Config.Promptkit,
Collection: *collection, })
Notifier: req.Notifier, if err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
return generatePromptReport(ctx, promptReportRequest{
GenerateRequest: req,
Resolved: resolved,
Collection: *collection,
Inspection: inspection,
}) })
} }
@@ -340,7 +347,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
item.MetadataPath = paths.Metadata item.MetadataPath = paths.Metadata
} }
outputPath := plannedBatchOutputPath(req.OutputDir, planned) outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{ reportResult, err := generateLegacyBatchReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
@@ -494,7 +501,9 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
return bundle, nil return bundle, nil
} }
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { // generateLegacyBatchReport is the temporary Scriptorium implementation used
// only by batch commands while their Promptkit migration is deferred.
func generateLegacyBatchReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle bundle := req.Collection.Bundle
if bundle == nil { if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required") return nil, fmt.Errorf("collected weather bundle is required")
@@ -724,40 +733,34 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
return nil, err return nil, err
} }
return renderedReportResult(reportResultRequest{ return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot, moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath, moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage, dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath, dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath, preflightPath: req.preflightPath,
reportPath: reportPath, reportPath: reportPath,
finalized: finalized, finalized: finalized,
priorSnapshot: req.priorSnapshot, priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges, recentChanges: req.recentChanges,
renderResult: req.renderResult, generatedTextRawPath: req.paths.GeneratedTextRaw,
structuredRunResult: structuredResult, generatedTextPath: generatedTextPath,
generatedTextRawPath: req.paths.GeneratedTextRaw, renderContextPath: renderContextPath,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err }), err
} }
return renderedReportResult(reportResultRequest{ return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot, moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath, moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage, dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath, dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath, preflightPath: req.preflightPath,
reportPath: reportPath, reportPath: reportPath,
finalized: finalized, finalized: finalized,
priorSnapshot: req.priorSnapshot, priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges, recentChanges: req.recentChanges,
renderResult: req.renderResult, generatedTextRawPath: req.paths.GeneratedTextRaw,
structuredRunResult: structuredResult, generatedTextPath: generatedTextPath,
generatedTextRawPath: req.paths.GeneratedTextRaw, renderContextPath: renderContextPath,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil }), nil
} }
@@ -770,46 +773,40 @@ func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
} }
type reportResultRequest struct { type reportResultRequest 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
finalized finalizeRenderedReportResult finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change recentChanges []changes.Change
renderResult *scriptorium.RenderResult generatedTextRawPath string
structuredRunResult *scriptorium.StructuredRunResult generatedTextPath string
generatedTextRawPath string renderContextPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
} }
func renderedReportResult(req reportResultRequest) *ReportResult { func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{ return &ReportResult{
ModuleSnapshot: req.moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath, ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage, DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreparationPath: req.preflightPath, PreparationPath: req.preflightPath,
ExecutionPath: req.generatedTextResultPath, ExecutionPath: req.finalized.Metadata.GeneratedTextResultPath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: req.reportPath, ReportPath: req.reportPath,
OutputPath: req.finalized.OutputPath, OutputPath: req.finalized.OutputPath,
NotificationPath: req.finalized.NotificationPath, NotificationPath: req.finalized.NotificationPath,
Metadata: req.finalized.Metadata, Metadata: req.finalized.Metadata,
MetadataPath: req.finalized.MetadataPath, MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot, PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, GeneratedTextRawPath: req.generatedTextRawPath,
StructuredRunResult: req.structuredRunResult, GeneratedTextPath: req.generatedTextPath,
GeneratedTextRawPath: req.generatedTextRawPath, RenderContextPath: req.renderContextPath,
GeneratedTextResultPath: req.generatedTextResultPath, Notification: req.finalized.Notification,
GeneratedTextPath: req.generatedTextPath,
RenderContextPath: req.renderContextPath,
Notification: req.finalized.Notification,
} }
} }

View File

@@ -18,6 +18,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"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/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
@@ -72,6 +73,56 @@ func TestFetchAndSaveBundle(t *testing.T) {
} }
} }
type promptExecutorTest struct {
err error
afterPreparationErr error
validation promptexec.ValidationStatus
}
func (e promptExecutorTest) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text")
return promptexec.PromptInspection{
PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile",
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"},
}, nil
}
func (e promptExecutorTest) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil
}
func (e promptExecutorTest) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if e.err != nil {
return nil, e.err
}
now := time.Now().UTC()
if err := callback(promptexec.Preparation{
PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash",
ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath,
StartedAt: now, EndedAt: now,
}, nil); err != nil {
return nil, err
}
if e.afterPreparationErr != nil {
return nil, e.afterPreparationErr
}
raw := []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`)
if request.PromptID == "weather.hourly_generated_text" {
raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`)
}
validation := e.validation
if validation == "" {
validation = promptexec.ValidationPassed
}
return &promptexec.Execution{
RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash",
ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash",
StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw,
Validation: promptexec.NewValidation(validation, "json_schema", "generated_text.schema.json", nil),
}, nil
}
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()}) _, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
if err == nil { if err == nil {
@@ -94,6 +145,7 @@ func TestGenerateUsesProvidedCollector(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector, Collector: collector,
Executor: promptExecutorTest{},
}) })
if err == nil { if err == nil {
t.Fatal("Generate() error = nil, want collector error") t.Fatal("Generate() error = nil, want collector error")
@@ -114,20 +166,13 @@ func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg) collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection} collector := &recordingCollector{result: &collection}
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nprintf render failed >&2\nexit 1\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
err := Generate(context.Background(), GenerateRequest{ err := Generate(context.Background(), GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportDaily, Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector, Collector: collector,
Executor: promptExecutorTest{err: errors.New("prompt execution failed")},
}) })
if err == nil { if err == nil {
t.Fatal("Generate() error = nil, want render error") t.Fatal("Generate() error = nil, want render error")
@@ -135,22 +180,12 @@ func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
if len(collector.requests) != 1 { if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want 1", len(collector.requests)) t.Fatalf("collector requests = %d, want 1", len(collector.requests))
} }
if _, statErr := os.Stat(markerPath); statErr != nil {
t.Fatalf("scriptorium marker stat error = %v, want report execution reached renderer", statErr)
}
} }
func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) { func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = "" cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nexit 0\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
collector := &recordingCollector{err: errors.New("collection unavailable")} collector := &recordingCollector{err: errors.New("collection unavailable")}
err := Generate(context.Background(), GenerateRequest{ err := Generate(context.Background(), GenerateRequest{
@@ -159,13 +194,11 @@ func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector, Collector: collector,
Executor: promptExecutorTest{},
}) })
if err == nil { if err == nil {
t.Fatal("Generate() error = nil, want collector error") t.Fatal("Generate() error = nil, want collector error")
} }
if _, statErr := os.Stat(markerPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("scriptorium marker stat error = %v, want marker absent after collection failure", statErr)
}
} }
func TestGenerateDetailedReturnsReportResult(t *testing.T) { func TestGenerateDetailedReturnsReportResult(t *testing.T) {
@@ -183,6 +216,7 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector, Collector: collector,
Executor: promptExecutorTest{},
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err) t.Fatalf("GenerateDetailed() error = %v", err)
@@ -196,7 +230,7 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
if result.OutputPath != outputPath { if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath) t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
} }
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath) assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreparationPath, result.ExecutionPath, result.ReportPath, result.MetadataPath, outputPath)
if len(collector.requests) != 1 { if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection", len(collector.requests)) t.Fatalf("collector requests = %d, want one collection", len(collector.requests))
} }
@@ -214,6 +248,7 @@ func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: &recordingCollector{err: wantErr}, Collector: &recordingCollector{err: wantErr},
Executor: promptExecutorTest{},
}) })
if !errors.Is(err, wantErr) { if !errors.Is(err, wantErr) {
t.Fatalf("Generate() error = %v, want underlying collector error", err) t.Fatalf("Generate() error = %v, want underlying collector error", err)
@@ -235,6 +270,7 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
Now: mustParse("2026-05-29T08:30:00-05:00"), Now: mustParse("2026-05-29T08:30:00-05:00"),
Collector: &recordingCollector{result: &collection}, Collector: &recordingCollector{result: &collection},
Notifier: notifier, Notifier: notifier,
Executor: promptExecutorTest{},
}) })
if err == nil { if err == nil {
t.Fatal("GenerateDetailed() error = nil, want notification error") t.Fatal("GenerateDetailed() error = nil, want notification error")
@@ -252,7 +288,7 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" { if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" {
t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath) t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath)
} }
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath) assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath)
if result.Notification != nil { if result.Notification != nil {
t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification) t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification)
} }
@@ -261,6 +297,87 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
} }
} }
func TestGenerateDetailedInspectsBeforeCollectionOrArtifactWrites(t *testing.T) {
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
collector := &recordingCollector{err: errors.New("collector must not run")}
_, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: collector, Executor: failingPromptInspectionExecutor{},
})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("GenerateDetailed() error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
}
if len(collector.requests) != 0 {
t.Fatalf("collector requests = %d, want inspection failure before collection", len(collector.requests))
}
entries, readErr := os.ReadDir(cfg.Workspace.Root)
if readErr != nil || len(entries) != 0 {
t.Fatalf("workspace entries = %#v, err %v, want none", entries, readErr)
}
}
func TestGenerateDetailedPersistsPromptFailureReceiptsWithoutRawOutput(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection},
Executor: promptExecutorTest{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider unavailable", nil)},
})
if err == nil || result == nil {
t.Fatalf("GenerateDetailed() result/error = %#v/%v, want partial result and error", result, err)
}
if result.PreparationPath == "" || result.ExecutionPath == "" || result.MetadataPath == "" || result.GeneratedTextRawPath != "" {
t.Fatalf("result paths = %#v, want preparation/execution/metadata without raw output", result)
}
if result.Metadata.SchemaVersion != state.MetadataSchemaVersion || result.Metadata.PreparationPath != result.PreparationPath || result.Metadata.ExecutionPath != result.ExecutionPath {
t.Fatalf("metadata = %#v, want linked V2 prompt artifacts", result.Metadata)
}
store := recordingFilesystemStore(t, cfg)
execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath)
if loadErr != nil || execution.Status != state.PromptExecutionFailed || execution.Paths.RawOutputPath != "" {
t.Fatalf("execution/load error = %#v/%v, want failed receipt without raw path", execution, loadErr)
}
}
func TestGenerateDetailedPersistsRawOutputForValidationRejection(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection},
Executor: promptExecutorTest{validation: promptexec.ValidationFailed},
})
if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.ValidationRejected {
t.Fatalf("GenerateDetailed() result/error/category = %#v/%v/%q, want validation rejection", result, err, promptexec.CategoryOf(err))
}
if result.GeneratedTextRawPath == "" || result.ExecutionPath == "" || result.GeneratedTextPath != "" {
t.Fatalf("result paths = %#v, want raw and execution paths only", result)
}
store := recordingFilesystemStore(t, cfg)
execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath)
if loadErr != nil || execution.Status != state.PromptExecutionValidationRejected || execution.Paths.RawOutputPath != result.GeneratedTextRawPath {
t.Fatalf("execution/load error = %#v/%v, want rejected execution with raw path", execution, loadErr)
}
}
type failingPromptInspectionExecutor struct{}
func (failingPromptInspectionExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
return promptexec.PromptInspection{}, errors.New("unavailable")
}
func (failingPromptInspectionExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{}, errors.New("unexpected")
}
func (failingPromptInspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
return nil, errors.New("unexpected")
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
@@ -283,7 +400,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
store := recordingFilesystemStore(t, cfg) store := recordingFilesystemStore(t, cfg)
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -292,7 +409,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if renderer.renderCalls != 1 { if renderer.renderCalls != 1 {
@@ -316,7 +433,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) { if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
t.Fatalf("store calls = %v, want prefix %s", store.calls, want) t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
} }
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath) assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath) snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil { if err != nil {
t.Fatalf("read module snapshot: %v", err) t.Fatalf("read module snapshot: %v", err)
@@ -520,14 +637,14 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
}, tt.now.Format(time.RFC3339)) }, tt.now.Format(time.RFC3339))
renderer := successfulGeneratedTextRenderer("") renderer := successfulGeneratedTextRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 { if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls render=%d structured=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls) t.Fatalf("renderer calls render=%d structured=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls)
@@ -667,7 +784,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
}`, }`,
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -675,7 +792,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if renderer.renderCalls != 1 { if renderer.renderCalls != 1 {
@@ -699,7 +816,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
result.DataPackagePath, result.DataPackagePath,
result.PreflightPath, result.PreflightPath,
result.GeneratedTextRawPath, result.GeneratedTextRawPath,
result.GeneratedTextResultPath, result.Metadata.GeneratedTextResultPath,
result.GeneratedTextPath, result.GeneratedTextPath,
result.RenderContextPath, result.RenderContextPath,
result.ReportPath, result.ReportPath,
@@ -762,15 +879,12 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if result.OutputPath != result.ReportPath { if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", 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 len(result.RecentChanges) != 0 { if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges) t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
} }
if result.Metadata.GeneratedTextSchemaID != "hourly" || if result.Metadata.GeneratedTextSchemaID != "hourly" ||
result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath || result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath ||
result.Metadata.GeneratedTextResultPath != result.GeneratedTextResultPath || result.Metadata.GeneratedTextResultPath == "" ||
result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.GeneratedTextPath != result.GeneratedTextPath ||
result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderContextPath != result.RenderContextPath ||
result.Metadata.RenderedReportPath != result.ReportPath { result.Metadata.RenderedReportPath != result.ReportPath {
@@ -792,7 +906,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
notifier.result = successfulNotificationResult() notifier.result = successfulNotificationResult()
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -802,7 +916,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if result.OutputPath != outputPath { if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
@@ -878,7 +992,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
} }
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -887,7 +1001,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if result.OutputPath != outputPath { if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
@@ -959,7 +1073,7 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(), structuredRunBody: validHourlyGeneratedTextJSON(),
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -969,11 +1083,11 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error") t.Fatal("generateLegacyBatchReport() error = nil, want notification error")
} }
var notificationErr *NotificationError var notificationErr *NotificationError
if !errors.As(err, &notificationErr) { if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err) t.Fatalf("generateLegacyBatchReport() error = %T %v, want NotificationError", err, err)
} }
if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") { if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want hourly notification context", err.Error()) t.Fatalf("error = %q, want hourly notification context", err.Error())
@@ -1010,7 +1124,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(), structuredRunBody: validHourlyGeneratedTextJSON(),
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1018,7 +1132,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
saved := readMetadataForTest(t, result.MetadataPath) saved := readMetadataForTest(t, result.MetadataPath)
@@ -1027,7 +1141,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
} }
if saved.GeneratedTextSchemaID != "hourly" || if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath || saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath || saved.GeneratedTextResultPath != result.Metadata.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath || saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath { saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved) t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
@@ -1047,7 +1161,7 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1055,7 +1169,7 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if len(notifier.requests) != 1 { if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
@@ -1094,7 +1208,7 @@ func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"), err: errors.New("scriptorium render exited with code 1: render failed"),
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1141,7 +1255,7 @@ func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(), structuredRunBody: validHourlyGeneratedTextJSON(),
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1176,7 +1290,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
"details": "not allowed" "details": "not allowed"
}`) }`)
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1205,7 +1319,7 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *te
resolved.Definition.TemplateID = "missing-template" resolved.Definition.TemplateID = "missing-template"
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1241,7 +1355,7 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1249,7 +1363,7 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if len(notifier.requests) != 0 { if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests) t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests)
@@ -1268,7 +1382,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
} }
outputPath := filepath.Join(t.TempDir(), "daily-copy.md") outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1277,7 +1391,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if result.Notification == nil { if result.Notification == nil {
t.Fatal("Notification = nil, want notification result") t.Fatal("Notification = nil, want notification result")
@@ -1343,7 +1457,7 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
notifier := &recordingNotifier{err: errors.New("upload rejected")} notifier := &recordingNotifier{err: errors.New("upload rejected")}
store := recordingFilesystemStore(t, cfg) store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1352,7 +1466,7 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error") t.Fatal("generateLegacyBatchReport() error = nil, want notification error")
} }
if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") { if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want notification context", err.Error()) t.Fatalf("error = %q, want notification context", err.Error())
@@ -1409,7 +1523,7 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
@@ -1417,7 +1531,7 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want generation error") t.Fatal("generateLegacyBatchReport() error = nil, want generation error")
} }
if len(notifier.requests) != 0 { if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests) t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests)
@@ -1440,7 +1554,7 @@ func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
store := recordingFilesystemStore(t, cfg) store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"), Renderer: successfulRenderer("# Daily Report\n"),
@@ -1448,10 +1562,10 @@ func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
Notifier: notifier, Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want collected bundle error") t.Fatal("generateLegacyBatchReport() error = nil, want collected bundle error")
} }
if !strings.Contains(err.Error(), "collected weather bundle is required") { if !strings.Contains(err.Error(), "collected weather bundle is required") {
t.Fatalf("GenerateReport() error = %q, want collected bundle context", err.Error()) t.Fatalf("generateLegacyBatchReport() error = %q, want collected bundle context", err.Error())
} }
if len(notifier.requests) != 0 { if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests) t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests)
@@ -1477,14 +1591,14 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"), err: errors.New("scriptorium render exited with code 1: render failed"),
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want render error") t.Fatal("generateLegacyBatchReport() error = nil, want render error")
} }
store := recordingFilesystemStore(t, cfg) store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved) paths, err := store.Paths(resolved)
@@ -1523,14 +1637,14 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"), structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want run error") t.Fatal("generateLegacyBatchReport() error = nil, want run error")
} }
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 { if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls) t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
@@ -1567,7 +1681,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("") renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: currentResolved, Resolved: currentResolved,
@@ -1575,7 +1689,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if len(result.RecentChanges) == 0 { if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot") t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
@@ -1605,7 +1719,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: currentResolved, Resolved: currentResolved,
@@ -1613,7 +1727,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 { if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls) t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls)
@@ -1667,14 +1781,14 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
}, "2026-05-29T18:00:00-05:00") }, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if renderer.runCalls != 0 { if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls) t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
@@ -1705,7 +1819,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") { if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") {
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
} }
assertPathsExist(t, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath) assertPathsExist(t, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
renderContext, err := os.ReadFile(result.RenderContextPath) renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil { if err != nil {
t.Fatalf("read render context: %v", err) t.Fatalf("read render context: %v", err)
@@ -1740,7 +1854,7 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
}, "2026-05-29T18:00:00-05:00") }, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: currentResolved, Resolved: currentResolved,
@@ -1748,7 +1862,7 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if result.PriorSnapshot == nil { if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot") t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot")
@@ -1771,7 +1885,7 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
Report: ReportDaily, Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: currentResolved, Resolved: currentResolved,
@@ -1779,7 +1893,7 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
if result.PriorSnapshot != nil { if result.PriorSnapshot != nil {
t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot) t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot)
@@ -1797,14 +1911,14 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("") renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1}) records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1})
@@ -1860,12 +1974,12 @@ func TestInspectPriorSnapshot(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("") renderer := successfulRenderer("")
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil { if _, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
t.Fatalf("GenerateReport(prior) error = %v", err) t.Fatalf("generateLegacyBatchReport(prior) error = %v", err)
} }
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store}) current, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store})
if err != nil { if err != nil {
t.Fatalf("GenerateReport(current) error = %v", err) t.Fatalf("generateLegacyBatchReport(current) error = %v", err)
} }
prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID}) prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID})
@@ -3373,14 +3487,14 @@ func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg), Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"), Renderer: successfulRenderer("# Daily Report\n"),
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("generateLegacyBatchReport() error = %v", err)
} }
return result return result
} }
@@ -3572,7 +3686,7 @@ func readBatchNotificationForTest(t *testing.T, path string) state.BatchDistribu
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) { func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper() t.Helper()
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want generated-text report error") t.Fatal("generateLegacyBatchReport() error = nil, want generated-text report error")
} }
text := err.Error() text := err.Error()
for _, want := range []string{ for _, want := range []string{

View File

@@ -0,0 +1,321 @@
package app
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
}
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store
var err error
if store == nil {
store, err = defaultStore(req.Config)
if err != nil {
return nil, err
}
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
result := &ReportResult{ReportPath: paths.RenderedReport}
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil {
return result, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, bundle)
if err != nil {
return result, generatedReportError(req.Resolved, req.Resolved.Metadata().RunID, "build report facts", err)
}
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts)
if err != nil {
return result, generatedReportError(req.Resolved, req.Resolved.Metadata().RunID, "build module snapshot", err)
}
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
if err != nil {
return result, err
}
result.ModuleSnapshot = moduleSnapshot
result.ModuleSnapshotPath = moduleSnapshotPath
result.PriorSnapshot = priorSnapshot
recent, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil {
return result, err
}
result.RecentChanges = recent
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildPromptMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preparation: paths.Preparation,
Execution: paths.Execution,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recent,
})
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "build data package", err)
}
data, err := promptinput.MarshalYAML(dataPackage)
if err != nil {
return result, err
}
dataPackagePath, err := store.SaveDataPackageBytes(ctx, req.Resolved, data)
if err != nil {
return result, err
}
metadata.DataPackagePath = dataPackagePath
result.DataPackage = dataPackage
result.DataPackagePath = dataPackagePath
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "lookup generated text catalog", err)
}
prepared := false
callbackFailed := false
callback := func(preparation promptexec.Preparation, _ *promptexec.PreparationDebug) error {
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion,
Status: state.PromptPreparationSucceeded,
ReportID: req.Resolved.Definition.ID,
RunID: metadata.RunID,
PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion,
DataPackagePath: dataPackagePath,
Preparation: &preparation,
StartedAt: preparation.StartedAt,
EndedAt: preparation.EndedAt,
Duration: preparation.Duration,
}
path, err := store.SavePromptPreparation(ctx, req.Resolved, artifact)
if err != nil {
callbackFailed = true
return err
}
prepared = true
result.PreparationPath = path
metadata.PreparationPath = path
metadataPath, err := store.SaveMetadata(ctx, metadata)
if err != nil {
callbackFailed = true
return err
}
result.Metadata = metadata
result.MetadataPath = metadataPath
return nil
}
execution, executeErr := req.Executor.Execute(ctx, promptexec.ExecuteRequest{
PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion,
ProfileID: req.Inspection.ProfileID,
DataPackage: data,
DataPackagePath: dataPackagePath,
CaptureDebug: false,
}, callback)
if executeErr != nil {
if callbackFailed {
return result, executeErr
}
executeErr = classifiedPromptError("prompt execution failed", executeErr)
if !prepared {
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationFailed,
ReportID: req.Resolved.Definition.ID, RunID: metadata.RunID, PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion, DataPackagePath: dataPackagePath,
StartedAt: time.Now(), EndedAt: time.Now(), Error: state.NewPromptArtifactError(executeErr),
}
path, saveErr := store.SavePromptPreparation(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.PreparationPath = path
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.PreparationPath, result.Metadata, result.MetadataPath = path, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "prepare prompt", executeErr)
}
if promptexec.CategoryOf(executeErr) != "" {
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, executeErr)
path, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = path
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = path, metadata, metadataPath
}
return result, generatedReportError(req.Resolved, metadata.RunID, "execute prompt", executeErr)
}
if execution == nil {
err := promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, err)
executionPath, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = executionPath
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "execute prompt", err)
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
err := promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, err)
executionPath, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = executionPath
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "validate prompt execution", err)
}
rawPath, err := store.SaveGeneratedTextRaw(ctx, req.Resolved, execution.RawOutput)
if err != nil {
return result, err
}
result.GeneratedTextRawPath = rawPath
executionArtifact := state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion,
ReportID: req.Resolved.Definition.ID, RunID: metadata.RunID,
PromptID: req.Inspection.PromptID, PromptVersion: req.Inspection.PromptVersion,
Provenance: ptr(state.PromptExecutionProvenanceFrom(*execution)), Validation: &execution.Validation,
Paths: state.PromptExecutionPaths{RawOutputPath: rawPath},
StartedAt: execution.StartedAt, EndedAt: execution.EndedAt, Duration: execution.Duration,
}
if execution.Validation.Status == promptexec.ValidationPassed {
executionArtifact.Status = state.PromptExecutionSucceeded
} else {
executionArtifact.Status = state.PromptExecutionValidationRejected
}
executionPath, err := store.SavePromptExecution(ctx, req.Resolved, executionArtifact)
if err != nil {
return result, err
}
metadata.ExecutionPath = executionPath
metadata.GeneratedTextRawPath = rawPath
metadataPath, err := store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
if execution.Validation.Status == promptexec.ValidationFailed {
return result, generatedReportError(req.Resolved, metadata.RunID, "validate prompt execution", promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil))
}
generatedText, normalized, err := handler.Validate(execution.RawOutput)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := store.SaveGeneratedText(ctx, req.Resolved, normalized)
if err != nil {
return result, err
}
metadata.GeneratedTextPath = generatedTextPath
metadataPath, err = store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.GeneratedTextPath, result.Metadata, result.MetadataPath = generatedTextPath, metadata, metadataPath
renderContext, err := handler.BuildRenderContext(briefingMetadata, moduleSnapshot, reportFacts.Collected, reportFacts.Derived, generatedText)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "build render context", err)
}
renderContextPath, err := store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return result, err
}
metadata.RenderContextPath = renderContextPath
metadataPath, err = store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.RenderContextPath, result.Metadata, result.MetadataPath = renderContextPath, metadata, metadataPath
rendered, err := handler.Render(renderContext)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "render template", err)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return result, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return result, err
}
result.ReportPath = reportPath
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config, Store: store, Resolved: req.Resolved, Metadata: metadata,
ManagedReportPath: reportPath, OutputPath: req.OutputPath, Notifier: req.Notifier,
})
result.OutputPath, result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
result.Metadata, result.MetadataPath, result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
if err != nil {
return result, err
}
return result, nil
}
func failedPromptExecutionArtifact(resolved report.Resolved, metadata state.Metadata, inspection PromptInspectionResult, err error) state.PromptExecutionArtifact {
now := time.Now()
return state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion, Status: state.PromptExecutionFailed,
ReportID: resolved.Definition.ID, RunID: metadata.RunID, PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion, StartedAt: now, EndedAt: now,
Error: state.NewPromptArtifactError(err),
}
}
func classifiedPromptError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.Generation, operation, err)
}
func ptr[T any](value T) *T { return &value }

View File

@@ -92,7 +92,7 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
} }
summary.ExecutionPath = result.ExecutionPath summary.ExecutionPath = result.ExecutionPath
if summary.ExecutionPath == "" { if summary.ExecutionPath == "" {
summary.ExecutionPath = result.GeneratedTextResultPath summary.ExecutionPath = result.Metadata.GeneratedTextResultPath
} }
summary.LLMDebugPath = result.LLMDebugPath summary.LLMDebugPath = result.LLMDebugPath
summary.GeneratedTextRawPath = result.GeneratedTextRawPath summary.GeneratedTextRawPath = result.GeneratedTextRawPath

View File

@@ -214,6 +214,10 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil { if err != nil {
return app.GenerateRequest{}, commonOptions{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
executor, err := r.promptExecutor(cfg.Promptkit)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil { if err != nil {
return app.GenerateRequest{}, commonOptions{}, err return app.GenerateRequest{}, commonOptions{}, err
@@ -224,6 +228,7 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
Report: reportKind, Report: reportKind,
OutputPath: opts.Output, OutputPath: opts.Output,
Now: r.Clock.Now(), Now: r.Clock.Now(),
Executor: executor,
} }
switch reportKind { switch reportKind {

View File

@@ -13,10 +13,51 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func testRunner() Runner {
return testRunnerWithClock(fixedClock())
}
func testRunnerWithClock(clock timeutil.Clock) Runner {
return Runner{Clock: clock, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return cliPromptExecutor{}, nil
}}
}
type cliPromptExecutor struct{}
func (cliPromptExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text")
return promptexec.PromptInspection{
PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile",
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"},
}, nil
}
func (cliPromptExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil
}
func (cliPromptExecutor) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
now := time.Now().UTC()
if err := callback(promptexec.Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath, StartedAt: now, EndedAt: now}, nil); err != nil {
return nil, err
}
raw := []byte(`{"summary": "Showers are possible during the selected day.", "forecast_discussion": ["A front will keep rain chances in the forecast."], "precipitation_timing": "Rain is most likely during the afternoon."}`)
if request.PromptID == "weather.today_generated_text" {
raw = []byte(`{"summary": "Today starts with showers before improving.", "forecast_discussion": ["Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter."], "precipitation_timing": "The best rain chance is during the morning."}`)
}
if request.PromptID == "weather.hourly_generated_text" {
raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region."}`)
}
return &promptexec.Execution{RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw, Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "generated_text.schema.json", nil)}, nil
}
func TestRunHelpLongFlag(t *testing.T) { func TestRunHelpLongFlag(t *testing.T) {
output, err := runRootCommand(t, "--help") output, err := runRootCommand(t, "--help")
if err != nil { if err != nil {
@@ -73,7 +114,7 @@ func TestRunUnknownCommand(t *testing.T) {
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("tomorrow.md") outPath := fixture.path("tomorrow.md")
runner := Runner{Clock: fixedClock()} runner := testRunner()
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"generate", "tomorrow", "generate", "tomorrow",
@@ -95,7 +136,7 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
func TestRunEveningGeneratesTomorrowReport(t *testing.T) { func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"run", "evening", "run", "evening",
@@ -113,7 +154,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) { func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"run", "morning", "run", "morning",
@@ -129,7 +170,7 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
fixture := newCLIFixture(t, writeFailingScriptorium) fixture := newCLIFixture(t, writeFailingScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"run", "morning", "run", "morning",
@@ -322,7 +363,7 @@ func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
outputDir := fixture.path("copies") outputDir := fixture.path("copies")
runner := Runner{Clock: fixedClock()} runner := testRunner()
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"run", "evening", "run", "evening",
@@ -349,7 +390,7 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) { func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"run", "evening", "run", "evening",
@@ -390,7 +431,7 @@ func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"run", "evening", "run", "evening",
@@ -442,7 +483,7 @@ func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T)
configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"run", "evening", "run", "evening",
@@ -490,7 +531,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
outPath := fixture.path("daily.md") outPath := fixture.path("daily.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "daily", "generate", "daily",
@@ -524,13 +565,13 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
!strings.Contains(string(data), "timezone: UTC") { !strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
} }
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "render.*.json") preparationPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "prompt_preparation.*.json")
preflight, err := os.ReadFile(preflightPath) preparation, err := os.ReadFile(preparationPath)
if err != nil { if err != nil {
t.Fatalf("read preflight: %v", err) t.Fatalf("read preparation: %v", err)
} }
if !strings.Contains(string(preflight), `ok`) { if !strings.Contains(string(preparation), `"status": "succeeded"`) {
t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) t.Fatalf("preparation missing successful status:\n%s", string(preparation))
} }
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md") _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json")
@@ -548,7 +589,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
outPath := fixture.path("today.md") outPath := fixture.path("today.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "today", "generate", "today",
@@ -612,7 +653,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
outPath := fixture.path("hourly.md") outPath := fixture.path("hourly.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}} runner := testRunnerWithClock(timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)})
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "hourly", "generate", "hourly",
@@ -650,7 +691,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`) assertFileContains(t, rawGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"Report": {`) assertFileContains(t, renderContextPath, `"Report": {`)
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`) assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
@@ -663,7 +704,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) { func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium) fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("today.md") outPath := fixture.path("today.md")
runner := Runner{Clock: fixedClock()} runner := testRunner()
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"generate", "today", "generate", "today",
@@ -684,7 +725,7 @@ func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) { func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr) err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr)
if err == nil { if err == nil {
@@ -706,7 +747,7 @@ func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "") t.Setenv("CLI_DISTRIBUTOR_TOKEN", "")
runner := Runner{Clock: fixedClock()} runner := testRunner()
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"generate", "daily", "generate", "daily",
@@ -733,7 +774,7 @@ func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
func TestRunInspectTodayArtifacts(t *testing.T) { func TestRunInspectTodayArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -778,7 +819,7 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
func TestRunInspectGeneratedArtifacts(t *testing.T) { func TestRunInspectGeneratedArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := testRunner()
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -826,7 +867,7 @@ func TestRunInspectMissingMetadata(t *testing.T) {
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr) err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil { if err == nil {
@@ -842,7 +883,7 @@ func TestRunInspectRejectsQuiet(t *testing.T) {
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := testRunner()
err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr) err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr)
if err == nil { if err == nil {
@@ -856,7 +897,7 @@ func TestRunInspectRejectsQuiet(t *testing.T) {
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
runner := Runner{Clock: fixedClock()} runner := testRunner()
commands := []string{"metadata", "modules", "data-package", "prior", "sources"} commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
for _, command := range commands { for _, command := range commands {
@@ -889,7 +930,7 @@ func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
} }
func TestResolveGenerateCommands(t *testing.T) { func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
tests := []struct { tests := []struct {
name string name string
args []string args []string
@@ -915,7 +956,7 @@ func TestResolveGenerateCommands(t *testing.T) {
} }
func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) { func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
for _, name := range report.CommandNames() { for _, name := range report.CommandNames() {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
args := []string{name} args := []string{name}
@@ -942,7 +983,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
} }
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) { func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n") configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n")
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"}) req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
@@ -968,7 +1009,7 @@ func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
} }
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) { func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
for _, args := range [][]string{ for _, args := range [][]string{
{"hourly", "--date", "2026-05-29"}, {"hourly", "--date", "2026-05-29"},
@@ -988,7 +1029,7 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
} }
func TestResolveGenerateDailyRequiresDate(t *testing.T) { func TestResolveGenerateDailyRequiresDate(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily"}) req, err := runner.resolveGenerate([]string{"daily"})
if err == nil { if err == nil {
@@ -1003,7 +1044,7 @@ func TestResolveGenerateDailyRequiresDate(t *testing.T) {
} }
func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) { func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
_, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"}) _, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"})
if err == nil { if err == nil {
@@ -1015,7 +1056,7 @@ func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
} }
func TestResolveGenerateDailyParsesDate(t *testing.T) { func TestResolveGenerateDailyParsesDate(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"}) req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"})
if err != nil { if err != nil {
@@ -1031,7 +1072,7 @@ func TestResolveGenerateDailyParsesDate(t *testing.T) {
} }
func TestResolveGenerateTodayDate(t *testing.T) { func TestResolveGenerateTodayDate(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
defaultReq, err := runner.resolveGenerate([]string{"today"}) defaultReq, err := runner.resolveGenerate([]string{"today"})
if err != nil { if err != nil {
@@ -1064,7 +1105,7 @@ func TestResolveGenerateTodayDate(t *testing.T) {
} }
func TestResolveGenerateAppliesSharedFlags(t *testing.T) { func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
if err != nil { if err != nil {
@@ -1083,7 +1124,7 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
} }
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) { func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
retired := strings.Join([]string{"near", "term"}, "-") retired := strings.Join([]string{"near", "term"}, "-")
_, err := runner.resolveGenerate([]string{retired}) _, err := runner.resolveGenerate([]string{retired})
@@ -1096,7 +1137,7 @@ func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
} }
func TestResolveGenerateRejectsRetiredReports(t *testing.T) { func TestResolveGenerateRejectsRetiredReports(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := testRunner()
for _, name := range []string{"three-day", "weekend", "storm"} { for _, name := range []string{"three-day", "weekend", "storm"} {
if _, err := runner.resolveGenerate([]string{name}); err == nil { if _, err := runner.resolveGenerate([]string{name}); err == nil {
t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name) t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name)

View File

@@ -122,12 +122,20 @@ func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.
}, snapshot) }, snapshot)
} }
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { func (s *FilesystemStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
data, err := promptinput.MarshalYAML(pkg)
if err != nil {
return "", err
}
return s.SaveDataPackageBytes(ctx, resolved, data)
}
func (s *FilesystemStore) SaveDataPackageBytes(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := promptinput.Save(paths.DataPackage, pkg); err != nil { if err := fileutil.WriteFileAtomic(paths.DataPackage, data); err != nil {
return "", err return "", err
} }
return paths.DataPackage, nil return paths.DataPackage, nil

View File

@@ -15,6 +15,7 @@ type Store interface {
Paths(report.Resolved) (ArtifactPaths, error) Paths(report.Resolved) (ArtifactPaths, error)
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error) SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SaveDataPackageBytes(context.Context, report.Resolved, []byte) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error) SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error)
SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error) SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error)