Validate generated text catalog before collection

This commit is contained in:
2026-08-13 02:22:09 +00:00
parent a18d5134c7
commit ef2634c2cb
10 changed files with 120 additions and 31 deletions

View File

@@ -16,7 +16,7 @@ The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded p
## Selection And Active Execution ## Selection And Active Execution
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order: Before weather collection, Weatherreporter validates the report's exact generated-text report/schema/template catalog binding, prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
1. explicit in-memory profiles used by an embedding consumer or test; 1. explicit in-memory profiles used by an embedding consumer or test;
2. the configured `profile_file` or `profile_dir`; 2. the configured `profile_file` or `profile_dir`;
@@ -37,8 +37,9 @@ filesystem path causes the requested execution to fail.
## Comparison Execution ## Comparison Execution
For `compare`, Weatherreporter validates one exact prompt and every explicitly For `compare`, Weatherreporter validates the report's generated-text catalog
selected profile before weather collection. It prepares one deterministic YAML binding, one exact prompt, and every explicitly selected profile before weather
collection. It prepares one deterministic YAML
data package, retains immutable copies of the report inputs, and executes every data package, retains immutable copies of the report inputs, and executes every
profile against the same exact data-package bytes. Each profile remains an profile against the same exact data-package bytes. Each profile remains an
independent Promptkit execution: one provider or validation failure does not independent Promptkit execution: one provider or validation failure does not

View File

@@ -7,7 +7,7 @@ is owned by the [CLI reference](../cli.md) and [operations guide](../operations.
## Single-Report Flow ## Single-Report Flow
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result. `GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the report's generated-text catalog binding, exact Promptkit prompt, and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run. The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run.
@@ -15,7 +15,7 @@ Failures return an active partial result with safe identity, profile, warning, v
## Batches ## Batches
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating prompt and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially. `RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating generated-text catalog, prompt, and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome. Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
@@ -23,8 +23,9 @@ Each item has an independent result. A failed item does not stop later items; su
`CompareDetailed` validates ordered explicit profile IDs, resolves the report, `CompareDetailed` validates ordered explicit profile IDs, resolves the report,
and preflights the exact bundle destination before initializing optional prompt and preflights the exact bundle destination before initializing optional prompt
debugging, prompt inspection, or collection. It then inspects the one prompt debugging, prompt inspection, or collection. It then validates the report's
and every selected profile, collects once, and delegates shared report generated-text catalog binding, inspects the one prompt and every selected
profile, collects once, and delegates shared report
construction to the prepared-report flow. It does not accept a notifier. construction to the prepared-report flow. It does not accept a notifier.
Once the destination is resolved, the partial result retains its absolute Once the destination is resolved, the partial result retains its absolute

View File

@@ -1,8 +1,10 @@
# Prepared Report Internals # Prepared Report Internals
`internal/app` builds a `preparedReport` after collection and before profile `internal/app` validates the report's generated-text catalog binding during
execution. This is the immutable boundary shared by ordinary report generation prompt inspection, before collection, and carries the resulting handler into
and profile comparison; it is not a durable artifact. `preparedReport` construction after collection. This is the immutable boundary
shared by ordinary report generation and profile comparison; it is not a
durable artifact.
Preparation first establishes one `PreparedIdentity` for the report run, report Preparation first establishes one `PreparedIdentity` for the report run, report
and prompt IDs, variant, generation time, units, timezone, valid period, and prompt IDs, variant, generation time, units, timezone, valid period,
@@ -21,5 +23,7 @@ bytes, and only then assembles the resulting logical bundle. The prompt-input
shape is owned by [prompt-input internals](prompt-input.md); profile execution shape is owned by [prompt-input internals](prompt-input.md); profile execution
semantics are owned by [Promptkit integration](../integrations/promptkit.md). semantics are owned by [Promptkit integration](../integrations/promptkit.md).
Preparation failure has no publication side effects. Tests for this boundary Catalog incompatibility stops prompt inspection before weather collection or
cover mutation isolation, byte equality, and reuse by both execution paths. model work. Preparation failure has no publication side effects. Tests for this
boundary cover catalog-preflight ordering, mutation isolation, byte equality,
and reuse by both execution paths.

View File

@@ -132,7 +132,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
if err != nil { if err != nil {
return result, err return result, err
} }
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection}) prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection, handler: inspection.handler})
if err != nil { if err != nil {
return result, fmt.Errorf("prepare comparison report: %w", err) return result, fmt.Errorf("prepare comparison report: %w", err)
} }

View File

@@ -31,6 +31,7 @@ type prepareReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
Collection collect.Result Collection collect.Result
handler generatedtext.Handler
} }
type preparationError struct { type preparationError struct {
@@ -69,11 +70,6 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "marshal data package", err: err} 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}
}
clonedDerived, err := clonePreparedValue(reportFacts.Derived) clonedDerived, err := clonePreparedValue(reportFacts.Derived)
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err} return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
@@ -93,7 +89,7 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
identity: clonedIdentity, identity: clonedIdentity,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
dataPackage: append([]byte(nil), serializedDataPackage...), dataPackage: append([]byte(nil), serializedDataPackage...),
handler: handler, handler: req.handler,
} }
return prepared, nil return prepared, nil
} }

View File

@@ -7,7 +7,9 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -22,7 +24,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}} request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)}
prepared, err := prepareReport(request) prepared, err := prepareReport(request)
if err != nil { if err != nil {
t.Fatalf("prepareReport() error = %v", err) t.Fatalf("prepareReport() error = %v", err)
@@ -73,7 +75,7 @@ func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}}) prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil { if err != nil {
t.Fatalf("prepareReport() error = %v", err) t.Fatalf("prepareReport() error = %v", err)
} }
@@ -108,3 +110,12 @@ func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
} }
} }
} }
func preparedHandler(t *testing.T, resolved report.Resolved) generatedtext.Handler {
t.Helper()
handler, err := generatedtext.LookupDefinition(resolved.Definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
return handler
}

View File

@@ -81,7 +81,7 @@ func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult)
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
bundle := generationBundle(t) bundle := generationBundle(t)
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}}) prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil { if err != nil {
t.Fatalf("prepareReport() error = %v", err) t.Fatalf("prepareReport() error = %v", err)
} }

View File

@@ -30,7 +30,7 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report
if result == nil { if result == nil {
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection) result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
} }
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection}) prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
if err != nil { if err != nil {
return result, generatedPreparationError(req.Resolved, result.RunID, err) return result, generatedPreparationError(req.Resolved, result.RunID, err)
} }

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
@@ -29,6 +30,7 @@ type PromptInspectionResult struct {
ProfileID string ProfileID string
BackendID string BackendID string
ModelName string ModelName string
handler generatedtext.Handler
} }
// PromptExecutionsInspectionRequest validates all prompt/profile combinations // PromptExecutionsInspectionRequest validates all prompt/profile combinations
@@ -56,6 +58,7 @@ type ComparisonInspectionResult struct {
PromptVersion string PromptVersion string
PromptHash string PromptHash string
Profiles []ComparisonProfileInspection Profiles []ComparisonProfileInspection
handler generatedtext.Handler
} }
// ComparisonProfileInspection contains one requested profile's safe effective // ComparisonProfileInspection contains one requested profile's safe effective
@@ -91,6 +94,10 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
profiles := map[string]promptexec.ProfileInspection{} profiles := map[string]promptexec.ProfileInspection{}
for _, resolved := range req.Resolved { for _, resolved := range req.Resolved {
definition := resolved.Definition definition := resolved.Definition
handler, err := generatedtext.LookupDefinition(definition)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
}
inspection, err := inspectPromptContract(ctx, req.Executor, definition) inspection, err := inspectPromptContract(ctx, req.Executor, definition)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -113,6 +120,7 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
results[definition.ID] = PromptInspectionResult{ results[definition.ID] = PromptInspectionResult{
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash, PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
handler: handler,
} }
} }
return results, nil return results, nil
@@ -130,6 +138,10 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil) return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
} }
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison generated-text catalog inspection failed", promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err))
}
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition) inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
if err != nil { if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err) return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
@@ -139,6 +151,7 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
PromptVersion: inspection.PromptVersion, PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash, PromptHash: inspection.PromptHash,
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)), Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
handler: handler,
} }
for _, profileID := range req.ProfileIDs { for _, profileID := range req.ProfileIDs {
profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv) profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)

View File

@@ -110,9 +110,7 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) { func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
first := inspectionResolved(t) first := inspectionResolved(t)
second := first second := inspectionResolvedFor(t, report.Today)
second.Definition.ID = report.Today
second.Definition.PromptID = "weather.today"
executor := &inspectionExecutor{ executor := &inspectionExecutor{
prompt: validPromptInspection(first.Definition), prompt: validPromptInspection(first.Definition),
profiles: map[string]promptexec.ProfileInspection{ profiles: map[string]promptexec.ProfileInspection{
@@ -132,6 +130,67 @@ func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
} }
} }
func TestPromptInspectionRejectsIncompatibleGeneratedTextCatalogBeforeExecutorWork(t *testing.T) {
base := inspectionResolved(t)
tests := []struct {
name string
resolved report.Resolved
inspect func(context.Context, report.Resolved, *inspectionExecutor) error
}{
{
name: "single report unknown template",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.TemplateID = "unknown"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecution(ctx, PromptInspectionRequest{Resolved: resolved, Executor: executor})
return err
},
},
{
name: "batch known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{Resolved: []report.Resolved{resolved}, Executor: executor})
return err
},
},
{
name: "comparison known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor})
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
executor := &inspectionExecutor{}
err := test.inspect(context.Background(), test.resolved, executor)
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("inspection error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
}
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
t.Fatalf("incompatible catalog performed executor work: prompts %#v profiles %#v executions %d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
})
}
}
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) { func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
resolved := inspectionResolved(t) resolved := inspectionResolved(t)
executor := &inspectionExecutor{ executor := &inspectionExecutor{
@@ -265,12 +324,16 @@ func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest,
} }
func inspectionResolved(t *testing.T) report.Resolved { func inspectionResolved(t *testing.T) report.Resolved {
return inspectionResolvedFor(t, report.Daily)
}
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
t.Helper() t.Helper()
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), if id == report.Daily {
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC), request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
Location: time.UTC, }
}) resolved, err := report.DefaultRegistry().Resolve(id, request)
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }