Extract immutable report preparation
This commit is contained in:
@@ -20,6 +20,7 @@ type generationCollector struct {
|
|||||||
bundle *weatherdata.Bundle
|
bundle *weatherdata.Bundle
|
||||||
err error
|
err error
|
||||||
called bool
|
called bool
|
||||||
|
calls int
|
||||||
beforeRun func()
|
beforeRun func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Re
|
|||||||
c.beforeRun()
|
c.beforeRun()
|
||||||
}
|
}
|
||||||
c.called = true
|
c.called = true
|
||||||
|
c.calls++
|
||||||
return &collect.Result{Bundle: c.bundle}, c.err
|
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"
|
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||||
bundle := generationBundle(t)
|
bundle := generationBundle(t)
|
||||||
executor := &generationExecutor{}
|
executor := &generationExecutor{}
|
||||||
|
collector := &generationCollector{bundle: &bundle}
|
||||||
workingDir := t.TempDir()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDetailed() error = %v", err)
|
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)
|
t.Fatalf("result = %#v", result)
|
||||||
}
|
}
|
||||||
if result.LLMDebugPath != "" {
|
if result.LLMDebugPath != "" {
|
||||||
|
|||||||
150
internal/app/prepared_report.go
Normal file
150
internal/app/prepared_report.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
64
internal/app/prepared_report_test.go
Normal file
64
internal/app/prepared_report_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"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/promptdebug"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"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/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type promptReportRequest struct {
|
type promptReportRequest struct {
|
||||||
@@ -31,11 +26,7 @@ type promptReportWorkflow struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
req promptReportRequest
|
req promptReportRequest
|
||||||
result *ReportResult
|
result *ReportResult
|
||||||
briefingMetadata briefing.Metadata
|
prepared preparedReport
|
||||||
reportFacts ReportFacts
|
|
||||||
moduleSnapshot module.Snapshot
|
|
||||||
dataPackage []byte
|
|
||||||
handler generatedtext.Handler
|
|
||||||
debugRef promptdebug.PromptDebugRef
|
debugRef promptdebug.PromptDebugRef
|
||||||
callbackFailed bool
|
callbackFailed bool
|
||||||
}
|
}
|
||||||
@@ -97,36 +88,23 @@ func initialReportResult(req GenerateRequest, resolved report.Resolved, inspecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *promptReportWorkflow) buildInputs() error {
|
func (w *promptReportWorkflow) buildInputs() error {
|
||||||
var err error
|
prepared, err := prepareReport(prepareReportRequest{Config: w.req.Config, Resolved: w.req.Resolved, Collection: w.req.Collection})
|
||||||
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
|
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
|
return w.reportError("prepare report", err)
|
||||||
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.prepared = prepared
|
||||||
w.result.SourceWarnings = append([]weatherdata.SourceWarning(nil), w.briefingMetadata.SourceWarnings...)
|
w.result.SourceWarnings = w.prepared.sourceWarningsCopy()
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(w.briefingMetadata), Modules: w.moduleSnapshot})
|
w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.prepared.resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID}
|
||||||
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}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
|
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
|
||||||
captureDebug := w.req.DebugWriter != nil && w.req.DebugWriter.Enabled()
|
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 {
|
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) {
|
func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, error) {
|
||||||
generatedText, _, err := w.handler.Validate(raw)
|
generatedText, _, err := w.prepared.handler.Validate(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return w.result, w.reportError("validate generated text", err)
|
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 {
|
if err != nil {
|
||||||
return w.result, w.reportError("build render context", err)
|
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 {
|
if err != nil {
|
||||||
return w.result, w.reportError("render template", err)
|
return w.result, w.reportError("render template", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user