Complete Promptkit batch execution cutover
This commit is contained in:
@@ -3,13 +3,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
@@ -17,7 +15,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
@@ -57,14 +54,15 @@ type GenerateRequest struct {
|
||||
}
|
||||
|
||||
type BatchRequest struct {
|
||||
Config config.Config
|
||||
Batch BatchKind
|
||||
Now time.Time
|
||||
OutputDir string
|
||||
Collector Collector
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
Config config.Config
|
||||
Batch BatchKind
|
||||
Now time.Time
|
||||
OutputDir string
|
||||
LLMDebugDir string
|
||||
Collector Collector
|
||||
Executor promptexec.Executor
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
}
|
||||
|
||||
type FetchBundleRequest struct {
|
||||
@@ -82,17 +80,6 @@ type ReportFacts struct {
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
|
||||
type ReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
Collection collect.Result
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
ModuleSnapshot module.Snapshot
|
||||
ModuleSnapshotPath string
|
||||
@@ -101,7 +88,6 @@ type ReportResult struct {
|
||||
PreparationPath string
|
||||
ExecutionPath string
|
||||
LLMDebugPath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
NotificationPath string
|
||||
@@ -202,11 +188,6 @@ func batchReportFailures(result *BatchResult) int {
|
||||
return failures
|
||||
}
|
||||
|
||||
type Renderer interface {
|
||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
||||
}
|
||||
|
||||
type Collector interface {
|
||||
Run(context.Context, collect.Request) (*collect.Result, error)
|
||||
}
|
||||
@@ -324,6 +305,22 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
||||
if err != nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||
}
|
||||
candidates, err := batchInspectionCandidates(req, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inspections, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
|
||||
Resolved: candidates,
|
||||
Executor: req.Executor,
|
||||
Promptkit: req.Config.Promptkit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -348,49 +345,35 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
item := batchReportResult(planned)
|
||||
if paths, err := store.Paths(resolved); err == nil {
|
||||
item.DataPackagePath = paths.DataPackage
|
||||
item.PreparationPath = paths.Preflight
|
||||
item.PreparationPath = paths.Preparation
|
||||
item.ExecutionPath = paths.Execution
|
||||
item.ReportPath = paths.RenderedReport
|
||||
item.MetadataPath = paths.Metadata
|
||||
}
|
||||
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
||||
reportResult, err := generateLegacyBatchReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Collection: *collection,
|
||||
Renderer: req.Renderer,
|
||||
Store: store,
|
||||
Notifier: req.Notifier,
|
||||
noNotify: true,
|
||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||
GenerateRequest: GenerateRequest{
|
||||
Config: req.Config,
|
||||
OutputPath: outputPath,
|
||||
Notifier: req.Notifier,
|
||||
Executor: req.Executor,
|
||||
Store: store,
|
||||
},
|
||||
Resolved: resolved,
|
||||
Collection: *collection,
|
||||
Inspection: inspections[resolved.Definition.ID],
|
||||
DebugWriter: debugWriter,
|
||||
noNotify: true,
|
||||
})
|
||||
if reportResult != nil {
|
||||
copyBatchReportPaths(&item, reportResult)
|
||||
}
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
var notificationErr *NotificationError
|
||||
if errors.As(err, ¬ificationErr) {
|
||||
item.NotificationStatus = "failed"
|
||||
item.NotificationError = notificationErr.Error()
|
||||
item.NotificationPipelineID = notificationErr.Request.PipelineID
|
||||
if paths, pathErr := store.Paths(resolved); pathErr == nil {
|
||||
item.NotificationPath = paths.Notification
|
||||
}
|
||||
}
|
||||
result.Failed++
|
||||
} else {
|
||||
item.Status = "succeeded"
|
||||
item.DataPackagePath = reportResult.DataPackagePath
|
||||
item.PreparationPath = reportResult.PreparationPath
|
||||
item.ExecutionPath = reportResult.ExecutionPath
|
||||
item.LLMDebugPath = reportResult.LLMDebugPath
|
||||
item.ReportPath = reportResult.ReportPath
|
||||
item.OutputPath = reportResult.OutputPath
|
||||
item.MetadataPath = reportResult.MetadataPath
|
||||
item.NotificationPath = reportResult.NotificationPath
|
||||
if reportResult.Notification != nil {
|
||||
item.NotificationStatus = reportResult.Notification.Status
|
||||
item.NotificationRunID = reportResult.Notification.RunID
|
||||
item.NotificationPipelineID = reportResult.Notification.PipelineID
|
||||
}
|
||||
result.Succeeded++
|
||||
}
|
||||
result.Reports = append(result.Reports, item)
|
||||
@@ -409,6 +392,51 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
|
||||
func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
|
||||
item.DataPackagePath = result.DataPackagePath
|
||||
item.PreparationPath = result.PreparationPath
|
||||
item.ExecutionPath = result.ExecutionPath
|
||||
item.LLMDebugPath = result.LLMDebugPath
|
||||
item.ReportPath = result.ReportPath
|
||||
item.OutputPath = result.OutputPath
|
||||
item.MetadataPath = result.MetadataPath
|
||||
item.NotificationPath = result.NotificationPath
|
||||
if result.Notification != nil {
|
||||
item.NotificationStatus = result.Notification.Status
|
||||
item.NotificationRunID = result.Notification.RunID
|
||||
item.NotificationPipelineID = result.Notification.PipelineID
|
||||
}
|
||||
}
|
||||
|
||||
func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := []report.ID{report.Tomorrow, report.Daily}
|
||||
if req.Batch == BatchMorning {
|
||||
ids = []report.ID{report.Today, report.Tomorrow, report.Daily}
|
||||
}
|
||||
date := timeutil.LocalDate(now, location).AddDate(0, 0, 2)
|
||||
candidates := make([]report.Resolved, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
resolveReq := report.ResolveRequest{Now: now, Location: location}
|
||||
if id == report.Daily {
|
||||
resolveReq.Date = date
|
||||
}
|
||||
resolved, err := registry.Resolve(id, resolveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, resolved)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
||||
resolved := planned.Resolved
|
||||
metadata := resolved.Metadata()
|
||||
@@ -507,315 +535,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("collected weather bundle is required")
|
||||
}
|
||||
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
defaultStore, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store = defaultStore
|
||||
}
|
||||
paths, err := store.Paths(req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
}, bundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
}, reportFacts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
GeneratedTextRaw: paths.GeneratedTextRaw,
|
||||
GeneratedTextResult: paths.GeneratedTextResult,
|
||||
GeneratedText: paths.GeneratedText,
|
||||
RenderContext: paths.RenderContext,
|
||||
})
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||
Metadata: promptMetadata(metadata),
|
||||
Modules: moduleSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata.DataPackagePath = dataPackagePath
|
||||
|
||||
renderer := req.Renderer
|
||||
if renderer == nil {
|
||||
renderer = scriptorium.Runner{
|
||||
Binary: req.Config.Scriptorium.Binary,
|
||||
ConfigPath: req.Config.Scriptorium.ConfigPath,
|
||||
Profile: req.Config.Scriptorium.Profile,
|
||||
Timeout: req.Config.Scriptorium.Timeout,
|
||||
ExtraArgs: req.Config.Scriptorium.ExtraArgs,
|
||||
}
|
||||
}
|
||||
renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{
|
||||
PromptID: req.Resolved.Definition.PromptID,
|
||||
DataPackagePath: dataPackagePath,
|
||||
})
|
||||
|
||||
preflightPath := paths.Preflight
|
||||
if renderResult != nil {
|
||||
var err error
|
||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
metadata.PreflightPath = preflightPath
|
||||
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
|
||||
if metadataErr != nil {
|
||||
return nil, metadataErr
|
||||
}
|
||||
if renderErr != nil {
|
||||
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
|
||||
}
|
||||
|
||||
return generateTextTemplateReport(ctx, generatedReportRequest{
|
||||
ReportRequest: req,
|
||||
store: store,
|
||||
paths: paths,
|
||||
moduleSnapshot: moduleSnapshot,
|
||||
moduleSnapshotPath: moduleSnapshotPath,
|
||||
reportFacts: reportFacts,
|
||||
dataPackage: dataPackage,
|
||||
dataPackagePath: dataPackagePath,
|
||||
briefingMetadata: briefingMetadata,
|
||||
metadata: metadata,
|
||||
metadataPath: metadataPath,
|
||||
preflightPath: preflightPath,
|
||||
priorSnapshot: priorSnapshot,
|
||||
recentChanges: recentChanges,
|
||||
renderResult: renderResult,
|
||||
renderer: renderer,
|
||||
})
|
||||
}
|
||||
|
||||
type generatedReportRequest struct {
|
||||
ReportRequest
|
||||
store state.Store
|
||||
paths state.ArtifactPaths
|
||||
moduleSnapshot module.Snapshot
|
||||
moduleSnapshotPath string
|
||||
reportFacts ReportFacts
|
||||
dataPackage promptinput.Package
|
||||
dataPackagePath string
|
||||
briefingMetadata briefing.Metadata
|
||||
metadata state.Metadata
|
||||
metadataPath string
|
||||
preflightPath string
|
||||
priorSnapshot *state.PriorSnapshot
|
||||
recentChanges []changes.Change
|
||||
renderResult *scriptorium.RenderResult
|
||||
renderer Renderer
|
||||
}
|
||||
|
||||
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
||||
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
|
||||
}
|
||||
|
||||
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
|
||||
PromptID: req.Resolved.Definition.PromptID,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
OutputPath: req.paths.GeneratedTextRaw,
|
||||
})
|
||||
generatedTextResultPath := req.paths.GeneratedTextResult
|
||||
if structuredResult != nil {
|
||||
var err error
|
||||
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.metadata.GeneratedTextResultPath = generatedTextResultPath
|
||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if runErr != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
|
||||
}
|
||||
|
||||
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
||||
}
|
||||
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
||||
}
|
||||
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.metadata.GeneratedTextPath = generatedTextPath
|
||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||
}
|
||||
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.metadata.RenderContextPath = renderContextPath
|
||||
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rendered, err := handler.Render(renderContext)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
|
||||
}
|
||||
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||
Config: req.Config,
|
||||
Store: req.store,
|
||||
Resolved: req.Resolved,
|
||||
Metadata: req.metadata,
|
||||
ManagedReportPath: reportPath,
|
||||
OutputPath: req.OutputPath,
|
||||
Notifier: req.Notifier,
|
||||
noNotify: req.noNotify,
|
||||
})
|
||||
if err != nil {
|
||||
if finalizeResultEmpty(finalized) {
|
||||
return nil, err
|
||||
}
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: req.moduleSnapshot,
|
||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
||||
dataPackage: req.dataPackage,
|
||||
dataPackagePath: req.dataPackagePath,
|
||||
preflightPath: req.preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: req.priorSnapshot,
|
||||
recentChanges: req.recentChanges,
|
||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
generatedTextPath: generatedTextPath,
|
||||
renderContextPath: renderContextPath,
|
||||
}), err
|
||||
}
|
||||
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: req.moduleSnapshot,
|
||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
||||
dataPackage: req.dataPackage,
|
||||
dataPackagePath: req.dataPackagePath,
|
||||
preflightPath: req.preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: req.priorSnapshot,
|
||||
recentChanges: req.recentChanges,
|
||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
generatedTextPath: generatedTextPath,
|
||||
renderContextPath: renderContextPath,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
|
||||
return result.OutputPath == "" &&
|
||||
result.NotificationPath == "" &&
|
||||
result.MetadataPath == "" &&
|
||||
result.Metadata.RunID == "" &&
|
||||
result.Notification == nil
|
||||
}
|
||||
|
||||
type reportResultRequest struct {
|
||||
moduleSnapshot module.Snapshot
|
||||
moduleSnapshotPath string
|
||||
dataPackage promptinput.Package
|
||||
dataPackagePath string
|
||||
preflightPath string
|
||||
reportPath string
|
||||
finalized finalizeRenderedReportResult
|
||||
priorSnapshot *state.PriorSnapshot
|
||||
recentChanges []changes.Change
|
||||
generatedTextRawPath string
|
||||
generatedTextPath string
|
||||
renderContextPath string
|
||||
}
|
||||
|
||||
func renderedReportResult(req reportResultRequest) *ReportResult {
|
||||
return &ReportResult{
|
||||
ModuleSnapshot: req.moduleSnapshot,
|
||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||
DataPackage: req.dataPackage,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
PreparationPath: req.preflightPath,
|
||||
ExecutionPath: req.finalized.Metadata.GeneratedTextResultPath,
|
||||
PreflightPath: req.preflightPath,
|
||||
ReportPath: req.reportPath,
|
||||
OutputPath: req.finalized.OutputPath,
|
||||
NotificationPath: req.finalized.NotificationPath,
|
||||
Metadata: req.finalized.Metadata,
|
||||
MetadataPath: req.finalized.MetadataPath,
|
||||
PriorSnapshot: req.priorSnapshot,
|
||||
RecentChanges: req.recentChanges,
|
||||
GeneratedTextRawPath: req.generatedTextRawPath,
|
||||
GeneratedTextPath: req.generatedTextPath,
|
||||
RenderContextPath: req.renderContextPath,
|
||||
Notification: req.finalized.Notification,
|
||||
}
|
||||
}
|
||||
|
||||
type finalizeRenderedReportRequest struct {
|
||||
Config config.Config
|
||||
Store state.Store
|
||||
@@ -1292,20 +1011,6 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
}
|
||||
}
|
||||
|
||||
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
||||
if result == nil {
|
||||
return state.PreflightArtifact{}
|
||||
}
|
||||
return state.PreflightArtifact{
|
||||
Command: append([]string(nil), result.Command...),
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
}
|
||||
}
|
||||
|
||||
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
48
internal/app/batch_execution_test.go
Normal file
48
internal/app/batch_execution_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||
req := BatchRequest{Config: cfg, Batch: BatchMorning, Now: now}
|
||||
candidates, err := batchInspectionCandidates(req, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||
}
|
||||
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||
}, prompts: map[string]promptexec.PromptInspection{}}
|
||||
for _, candidate := range candidates {
|
||||
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
|
||||
}
|
||||
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
|
||||
return nil, errors.New("collection reached")
|
||||
})
|
||||
req.Executor = executor
|
||||
req.Collector = collector
|
||||
_, err = RunBatchDetailed(context.Background(), req)
|
||||
if err == nil || err.Error() != "collection reached" {
|
||||
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
|
||||
}
|
||||
if len(executor.promptRequests) != 3 || len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||
}
|
||||
}
|
||||
|
||||
type collectorFunc func(context.Context, collect.Request) (*collect.Result, error)
|
||||
|
||||
func (f collectorFunc) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
var _ Collector = collectorFunc(nil)
|
||||
@@ -21,6 +21,7 @@ type promptReportRequest struct {
|
||||
Collection collect.Result
|
||||
Inspection PromptInspectionResult
|
||||
DebugWriter *state.PromptDebugWriter
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
|
||||
@@ -315,7 +316,7 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report
|
||||
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,
|
||||
ManagedReportPath: reportPath, OutputPath: req.OutputPath, Notifier: req.Notifier, noNotify: req.noNotify,
|
||||
})
|
||||
result.OutputPath, result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
|
||||
result.Metadata, result.MetadataPath, result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
|
||||
|
||||
@@ -30,64 +30,100 @@ type PromptInspectionResult struct {
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||
// needed by a batch before collection begins.
|
||||
type PromptExecutionsInspectionRequest struct {
|
||||
Resolved []report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// InspectPromptExecution validates the exact prompt and profile needed for a
|
||||
// report before collection, execution, or durable writes begin.
|
||||
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
||||
results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
|
||||
Resolved: []report.Resolved{req.Resolved},
|
||||
Executor: req.Executor,
|
||||
Promptkit: req.Promptkit,
|
||||
LookupEnv: req.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, err
|
||||
}
|
||||
return results[req.Resolved.Definition.ID], nil
|
||||
}
|
||||
|
||||
// InspectPromptExecutions validates exact prompt contracts and their unique
|
||||
// effective profiles. It performs no collection, execution, or durable write.
|
||||
func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) {
|
||||
if req.Executor == nil {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||
}
|
||||
definition := req.Resolved.Definition
|
||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||
results := make(map[report.ID]PromptInspectionResult, len(req.Resolved))
|
||||
profiles := map[string]promptexec.ProfileInspection{}
|
||||
for _, resolved := range req.Resolved {
|
||||
definition := resolved.Definition
|
||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||
}
|
||||
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||
if err != nil {
|
||||
return nil, promptInspectionError("prompt inspection failed", err)
|
||||
}
|
||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||
}
|
||||
if !validPromptInput(inspection.Inputs) {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||
}
|
||||
if !validPromptOutput(definition, inspection.Output) {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||
}
|
||||
profileID := req.Promptkit.Profile
|
||||
if profileID == "" {
|
||||
profileID = inspection.DefaultProfileID
|
||||
}
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
|
||||
}
|
||||
profile, ok := profiles[profileID]
|
||||
if !ok {
|
||||
profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles[profileID] = profile
|
||||
}
|
||||
results[definition.ID] = PromptInspectionResult{
|
||||
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||
}
|
||||
}
|
||||
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
|
||||
profile, err := executor.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, promptInspectionError("prompt inspection failed", err)
|
||||
}
|
||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||
}
|
||||
if !validPromptInput(inspection.Inputs) {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||
}
|
||||
if !validPromptOutput(definition, inspection.Output) {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||
}
|
||||
profileID := req.Promptkit.Profile
|
||||
if profileID == "" {
|
||||
profileID = inspection.DefaultProfileID
|
||||
}
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
|
||||
}
|
||||
profile, err := req.Executor.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, promptInspectionError("profile inspection failed", err)
|
||||
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
|
||||
}
|
||||
if profile.ProfileID != profileID {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
|
||||
}
|
||||
if profile.CredentialRequired {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
lookupEnv := req.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
value, present := lookupEnv(profile.APIKeyEnv)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||
}
|
||||
}
|
||||
return PromptInspectionResult{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
ProfileID: profile.ProfileID,
|
||||
BackendID: profile.BackendID,
|
||||
ModelName: profile.ModelName,
|
||||
}, nil
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func validPromptInput(inputs []promptexec.InputDefinition) bool {
|
||||
|
||||
@@ -107,6 +107,30 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||
first := inspectionResolved(t)
|
||||
second := first
|
||||
second.Definition.ID = report.Today
|
||||
second.Definition.PromptID = "weather.today"
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(first.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||
},
|
||||
}
|
||||
executor.prompts = map[string]promptexec.PromptInspection{
|
||||
first.Definition.PromptID: validPromptInspection(first.Definition),
|
||||
second.Definition.PromptID: validPromptInspection(second.Definition),
|
||||
}
|
||||
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecutions() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 || len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
|
||||
}
|
||||
}
|
||||
|
||||
type inspectionPromptRequest struct {
|
||||
id string
|
||||
version string
|
||||
@@ -114,6 +138,7 @@ type inspectionPromptRequest struct {
|
||||
|
||||
type inspectionExecutor struct {
|
||||
prompt promptexec.PromptInspection
|
||||
prompts map[string]promptexec.PromptInspection
|
||||
profiles map[string]promptexec.ProfileInspection
|
||||
promptErr error
|
||||
promptRequests []inspectionPromptRequest
|
||||
@@ -125,6 +150,9 @@ func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version
|
||||
if e.promptErr != nil {
|
||||
return promptexec.PromptInspection{}, e.promptErr
|
||||
}
|
||||
if prompt, ok := e.prompts[id]; ok {
|
||||
return prompt, nil
|
||||
}
|
||||
return e.prompt, nil
|
||||
}
|
||||
|
||||
|
||||
21
internal/app/test_helpers_test.go
Normal file
21
internal/app/test_helpers_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func requireNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user