From 4fa873983d61855639546e884843c309658cfd05 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 2 Aug 2026 05:30:52 +0000 Subject: [PATCH] Extract immutable report preparation --- internal/app/generation_test.go | 7 +- internal/app/prepared_report.go | 150 +++++++++++++++++++++++++++ internal/app/prepared_report_test.go | 64 ++++++++++++ internal/app/prompt_generate.go | 64 ++++-------- 4 files changed, 242 insertions(+), 43 deletions(-) create mode 100644 internal/app/prepared_report.go create mode 100644 internal/app/prepared_report_test.go diff --git a/internal/app/generation_test.go b/internal/app/generation_test.go index 9facaec..d086673 100644 --- a/internal/app/generation_test.go +++ b/internal/app/generation_test.go @@ -20,6 +20,7 @@ type generationCollector struct { bundle *weatherdata.Bundle err error called bool + calls int beforeRun func() } @@ -28,6 +29,7 @@ func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Re c.beforeRun() } c.called = true + c.calls++ return &collect.Result{Bundle: c.bundle}, c.err } @@ -93,12 +95,13 @@ func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) { cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home" bundle := generationBundle(t) executor := &generationExecutor{} + collector := &generationCollector{bundle: &bundle} workingDir := t.TempDir() - result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: executor}) + result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: collector, Executor: executor}) if err != nil { t.Fatalf("GenerateDetailed() error = %v", err) } - if !executor.called || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" { + if !executor.called || collector.calls != 1 || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" { t.Fatalf("result = %#v", result) } if result.LLMDebugPath != "" { diff --git a/internal/app/prepared_report.go b/internal/app/prepared_report.go new file mode 100644 index 0000000..613d8fb --- /dev/null +++ b/internal/app/prepared_report.go @@ -0,0 +1,150 @@ +package app + +import ( + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +// preparedReport contains the immutable deterministic inputs shared by prompt +// executions for one resolved report. +type preparedReport struct { + resolved report.Resolved + reportFacts ReportFacts + moduleSnapshot module.Snapshot + briefingMetadata briefing.Metadata + sourceWarnings []weatherdata.SourceWarning + dataPackage []byte + handler generatedtext.Handler +} + +type prepareReportRequest struct { + Config config.Config + Resolved report.Resolved + Collection collect.Result +} + +type preparationError struct { + operation string + err error +} + +func (e *preparationError) Error() string { + return e.operation + ": " + e.err.Error() +} + +func (e *preparationError) Unwrap() error { + return e.err +} + +func prepareReport(req prepareReportRequest) (preparedReport, error) { + if req.Collection.Bundle == nil { + return preparedReport{}, &preparationError{operation: "prepare report", err: fmt.Errorf("collected weather bundle is required")} + } + + reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, req.Collection.Bundle) + if err != nil { + return preparedReport{}, &preparationError{operation: "build report facts", err: err} + } + moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts) + if err != nil { + return preparedReport{}, &preparationError{operation: "build module snapshot", err: err} + } + metadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) + dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot}) + if err != nil { + return preparedReport{}, &preparationError{operation: "build data package", err: err} + } + serializedDataPackage, err := promptinput.MarshalYAML(dataPackage) + if err != nil { + return preparedReport{}, &preparationError{operation: "marshal data package", err: err} + } + handler, err := generatedtext.LookupDefinition(req.Resolved.Definition) + if err != nil { + return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err} + } + + clonedFacts, err := clonePreparedValue(reportFacts) + if err != nil { + return preparedReport{}, &preparationError{operation: "copy prepared report facts", err: err} + } + clonedSnapshot, err := clonePreparedValue(moduleSnapshot) + if err != nil { + return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err} + } + clonedMetadata, err := clonePreparedValue(metadata) + if err != nil { + return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err} + } + prepared := preparedReport{ + resolved: cloneResolved(req.Resolved), + reportFacts: clonedFacts, + moduleSnapshot: clonedSnapshot, + briefingMetadata: clonedMetadata, + sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...), + dataPackage: append([]byte(nil), serializedDataPackage...), + handler: handler, + } + return prepared, nil +} + +func cloneResolved(value report.Resolved) report.Resolved { + cloned := value + cloned.Definition.DistributorPathTemplates = append([]string(nil), value.Definition.DistributorPathTemplates...) + cloned.Definition.Modules = make([]module.ConfigItem, len(value.Definition.Modules)) + for i, item := range value.Definition.Modules { + cloned.Definition.Modules[i] = item + switch options := item.Options.(type) { + case module.AreaForecastDiscussionOptions: + options.Sections = append([]string(nil), options.Sections...) + cloned.Definition.Modules[i].Options = options + } + } + return cloned +} + +func (p preparedReport) dataPackageCopy() []byte { + return append([]byte(nil), p.dataPackage...) +} + +func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning { + return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...) +} + +func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) { + metadata, err := clonePreparedValue(p.briefingMetadata) + if err != nil { + return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + } + snapshot, err := clonePreparedValue(p.moduleSnapshot) + if err != nil { + return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + } + reportFacts, err := clonePreparedValue(p.reportFacts) + if err != nil { + return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err + } + return metadata, snapshot, reportFacts, nil +} + +func clonePreparedValue[T any](value T) (T, error) { + encoded, err := json.Marshal(value) + if err != nil { + var zero T + return zero, fmt.Errorf("marshal immutable prepared value: %w", err) + } + var cloned T + if err := json.Unmarshal(encoded, &cloned); err != nil { + var zero T + return zero, fmt.Errorf("unmarshal immutable prepared value: %w", err) + } + return cloned, nil +} diff --git a/internal/app/prepared_report_test.go b/internal/app/prepared_report_test.go new file mode 100644 index 0000000..c090f63 --- /dev/null +++ b/internal/app/prepared_report_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "bytes" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { + cfg := generationConfig() + bundle := generationBundle(t) + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, Report: ReportDaily, + Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), + }, generationTime("2026-05-29T08:30:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + + request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}} + prepared, err := prepareReport(request) + if err != nil { + t.Fatalf("prepareReport() error = %v", err) + } + repeated, err := prepareReport(request) + if err != nil { + t.Fatalf("second prepareReport() error = %v", err) + } + if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) { + t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata) + } + + originalDataPackage := append([]byte(nil), prepared.dataPackage...) + originalMetadata := prepared.briefingMetadata + originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) + metadata, snapshot, reportFacts, err := prepared.renderInputs() + if err != nil { + t.Fatalf("renderInputs() error = %v", err) + } + metadata.SourceWarnings = append(metadata.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"}) + snapshot.Outputs = nil + reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation" + bundle.Hourly.Periods[0].TextDescription = "mutated after preparation" + bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"}) + if len(bundle.Sources) > 0 { + if bundle.Sources[0].Query == nil { + bundle.Sources[0].Query = map[string]string{} + } + bundle.Sources[0].Query["mutated"] = "true" + } + + if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { + t.Fatalf("prepared values changed after caller mutation: %#v", prepared) + } + if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { + t.Fatal("prepared report facts retain caller-owned weather data") + } + if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "consumer mutation" || len(prepared.moduleSnapshot.Outputs) == 0 { + t.Fatal("prepared report values retain consumer mutation") + } +} diff --git a/internal/app/prompt_generate.go b/internal/app/prompt_generate.go index c1310af..c7a0236 100644 --- a/internal/app/prompt_generate.go +++ b/internal/app/prompt_generate.go @@ -5,16 +5,11 @@ import ( "errors" "fmt" - "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/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug" "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/weatherdata" ) type promptReportRequest struct { @@ -28,16 +23,12 @@ type promptReportRequest struct { } type promptReportWorkflow struct { - ctx context.Context - req promptReportRequest - result *ReportResult - briefingMetadata briefing.Metadata - reportFacts ReportFacts - moduleSnapshot module.Snapshot - dataPackage []byte - handler generatedtext.Handler - debugRef promptdebug.PromptDebugRef - callbackFailed bool + ctx context.Context + req promptReportRequest + result *ReportResult + prepared preparedReport + debugRef promptdebug.PromptDebugRef + callbackFailed bool } func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) { @@ -97,36 +88,23 @@ func initialReportResult(req GenerateRequest, resolved report.Resolved, inspecti } func (w *promptReportWorkflow) buildInputs() error { - var err error - w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle) + prepared, err := prepareReport(prepareReportRequest{Config: w.req.Config, Resolved: w.req.Resolved, Collection: w.req.Collection}) if err != nil { - return w.reportError("build report facts", err) + var preparation *preparationError + if errors.As(err, &preparation) { + return w.reportError(preparation.operation, preparation.err) + } + return w.reportError("prepare report", err) } - w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts) - if err != nil { - return w.reportError("build module snapshot", err) - } - w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected)) - w.result.SourceWarnings = append([]weatherdata.SourceWarning(nil), w.briefingMetadata.SourceWarnings...) - dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(w.briefingMetadata), Modules: w.moduleSnapshot}) - if err != nil { - return w.reportError("build data package", err) - } - w.dataPackage, err = promptinput.MarshalYAML(dataPackage) - if err != nil { - return w.reportError("marshal data package", err) - } - w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition) - if err != nil { - return w.reportError("lookup generated text catalog", err) - } - w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID} + w.prepared = prepared + w.result.SourceWarnings = w.prepared.sourceWarningsCopy() + w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.prepared.resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID} return nil } func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) { captureDebug := w.req.DebugWriter != nil && w.req.DebugWriter.Enabled() - return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion, ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackage, CaptureDebug: captureDebug}, w.writePreparationDebug) + return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion, ProfileID: w.req.Inspection.ProfileID, DataPackage: w.prepared.dataPackageCopy(), CaptureDebug: captureDebug}, w.writePreparationDebug) } func (w *promptReportWorkflow) writePreparationDebug(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error { @@ -158,15 +136,19 @@ func (w *promptReportWorkflow) writeExecutionDebug(execution promptexec.Executio } func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, error) { - generatedText, _, err := w.handler.Validate(raw) + generatedText, _, err := w.prepared.handler.Validate(raw) if err != nil { return w.result, w.reportError("validate generated text", err) } - renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText) + metadata, snapshot, reportFacts, err := w.prepared.renderInputs() + if err != nil { + return w.result, w.reportError("copy prepared render inputs", err) + } + renderContext, err := w.prepared.handler.BuildRenderContext(metadata, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText) if err != nil { return w.result, w.reportError("build render context", err) } - rendered, err := w.handler.Render(renderContext) + rendered, err := w.prepared.handler.Render(renderContext) if err != nil { return w.result, w.reportError("render template", err) }