Add durable prompt execution state records
This commit is contained in:
@@ -94,6 +94,9 @@ type ReportResult struct {
|
||||
ModuleSnapshotPath string
|
||||
DataPackage promptinput.Package
|
||||
DataPackagePath string
|
||||
PreparationPath string
|
||||
ExecutionPath string
|
||||
LLMDebugPath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
@@ -156,7 +159,9 @@ type BatchReportResult struct {
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
PreparationPath string `json:"preparationPath,omitempty"`
|
||||
ExecutionPath string `json:"executionPath,omitempty"`
|
||||
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
@@ -330,7 +335,7 @@ 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.PreflightPath = paths.Preflight
|
||||
item.PreparationPath = paths.Preflight
|
||||
item.ReportPath = paths.RenderedReport
|
||||
item.MetadataPath = paths.Metadata
|
||||
}
|
||||
@@ -361,7 +366,9 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
} else {
|
||||
item.Status = "succeeded"
|
||||
item.DataPackagePath = reportResult.DataPackagePath
|
||||
item.PreflightPath = reportResult.PreflightPath
|
||||
item.PreparationPath = reportResult.PreparationPath
|
||||
item.ExecutionPath = reportResult.ExecutionPath
|
||||
item.LLMDebugPath = reportResult.LLMDebugPath
|
||||
item.ReportPath = reportResult.ReportPath
|
||||
item.OutputPath = reportResult.OutputPath
|
||||
item.MetadataPath = reportResult.MetadataPath
|
||||
@@ -786,6 +793,8 @@ func renderedReportResult(req reportResultRequest) *ReportResult {
|
||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||
DataPackage: req.dataPackage,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
PreparationPath: req.preflightPath,
|
||||
ExecutionPath: req.generatedTextResultPath,
|
||||
PreflightPath: req.preflightPath,
|
||||
ReportPath: req.reportPath,
|
||||
OutputPath: req.finalized.OutputPath,
|
||||
|
||||
@@ -17,26 +17,27 @@ const (
|
||||
)
|
||||
|
||||
type generateSummary struct {
|
||||
Command string `json:"command"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
ReportName string `json:"reportName"`
|
||||
PromptID string `json:"promptId"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Command string `json:"command"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
ReportName string `json:"reportName"`
|
||||
PromptID string `json:"promptId"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||
PreparationPath string `json:"preparationPath,omitempty"`
|
||||
ExecutionPath string `json:"executionPath,omitempty"`
|
||||
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type generateNotificationSummary struct {
|
||||
@@ -85,9 +86,16 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||
summary.OutputPath = result.OutputPath
|
||||
summary.MetadataPath = result.MetadataPath
|
||||
summary.DataPackagePath = result.DataPackagePath
|
||||
summary.PreflightPath = result.PreflightPath
|
||||
summary.PreparationPath = result.PreparationPath
|
||||
if summary.PreparationPath == "" {
|
||||
summary.PreparationPath = result.PreflightPath
|
||||
}
|
||||
summary.ExecutionPath = result.ExecutionPath
|
||||
if summary.ExecutionPath == "" {
|
||||
summary.ExecutionPath = result.GeneratedTextResultPath
|
||||
}
|
||||
summary.LLMDebugPath = result.LLMDebugPath
|
||||
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
||||
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||
summary.RenderContextPath = result.RenderContextPath
|
||||
summary.NotificationPath = result.NotificationPath
|
||||
|
||||
@@ -19,16 +19,16 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||
startedAt := acceptedAt.Add(time.Minute)
|
||||
finishedAt := startedAt.Add(time.Minute)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||
PreflightPath: "/runs/hourly/preflight.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
|
||||
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||
RenderContextPath: "/runs/hourly/render_context.json",
|
||||
NotificationPath: "/runs/hourly/notification.json",
|
||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||
PreparationPath: "/runs/hourly/preparation.json",
|
||||
ExecutionPath: "/runs/hourly/execution.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||
RenderContextPath: "/runs/hourly/render_context.json",
|
||||
NotificationPath: "/runs/hourly/notification.json",
|
||||
Metadata: state.Metadata{
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
@@ -58,7 +58,7 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
||||
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||
}
|
||||
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||
if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||
}
|
||||
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||
@@ -71,13 +71,16 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") {
|
||||
t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||
PreflightPath: "/runs/daily/preflight.json",
|
||||
PreparationPath: "/runs/daily/preparation.json",
|
||||
ReportPath: "/runs/daily/report.md",
|
||||
OutputPath: "/copies/daily.md",
|
||||
MetadataPath: "/runs/daily/metadata.json",
|
||||
@@ -113,7 +116,7 @@ func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||
PreflightPath: "/runs/hourly/preflight.json",
|
||||
PreparationPath: "/runs/hourly/preparation.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
|
||||
@@ -596,10 +596,10 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today {
|
||||
t.Fatalf("generate summary = %#v, want successful Today summary", summary)
|
||||
}
|
||||
if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
|
||||
if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" {
|
||||
t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary)
|
||||
}
|
||||
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||
if summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||
}
|
||||
if summary.OutputPath != outPath {
|
||||
|
||||
@@ -27,13 +27,19 @@ type FilesystemStore struct {
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||
Metadata string `json:"metadata"`
|
||||
DataPackage string `json:"dataPackage"`
|
||||
Preflight string `json:"preflight"`
|
||||
Notification string `json:"notification,omitempty"`
|
||||
RenderedReport string `json:"renderedReport,omitempty"`
|
||||
GeneratedTextRaw string `json:"generatedTextRaw,omitempty"`
|
||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||
Metadata string `json:"metadata"`
|
||||
DataPackage string `json:"dataPackage"`
|
||||
Preparation string `json:"preparation,omitempty"`
|
||||
Execution string `json:"execution,omitempty"`
|
||||
// Preflight is retained for the temporary Scriptorium write path. Remove it
|
||||
// with that integration's cutover.
|
||||
Preflight string `json:"preflight"`
|
||||
Notification string `json:"notification,omitempty"`
|
||||
RenderedReport string `json:"renderedReport,omitempty"`
|
||||
GeneratedTextRaw string `json:"generatedTextRaw,omitempty"`
|
||||
// GeneratedTextResult is retained for the temporary Scriptorium write path.
|
||||
// Remove it with that integration's cutover.
|
||||
GeneratedTextResult string `json:"generatedTextResult,omitempty"`
|
||||
GeneratedText string `json:"generatedText,omitempty"`
|
||||
RenderContext string `json:"renderContext,omitempty"`
|
||||
@@ -95,6 +101,8 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"),
|
||||
Preparation: s.join(s.preflightDir, group, validDate, "prompt_preparation."+metadata.RunID+".json"),
|
||||
Execution: s.join(s.snapshotsDir, group, validDate, "prompt_execution."+metadata.RunID+".json"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"),
|
||||
@@ -131,6 +139,30 @@ func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resol
|
||||
}, artifact)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SavePromptPreparation(_ context.Context, resolved report.Resolved, artifact PromptPreparationArtifact) (string, error) {
|
||||
if artifact.SchemaVersion == "" {
|
||||
artifact.SchemaVersion = PromptPreparationSchemaVersion
|
||||
}
|
||||
if err := artifact.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
|
||||
return paths.Preparation
|
||||
}, artifact)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SavePromptExecution(_ context.Context, resolved report.Resolved, artifact PromptExecutionArtifact) (string, error) {
|
||||
if artifact.SchemaVersion == "" {
|
||||
artifact.SchemaVersion = PromptExecutionSchemaVersion
|
||||
}
|
||||
if err := artifact.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
|
||||
return paths.Execution
|
||||
}, artifact)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) {
|
||||
if artifact.SchemaVersion == "" {
|
||||
artifact.SchemaVersion = DistributorNotificationSchemaVersion
|
||||
@@ -227,20 +259,8 @@ func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved repo
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
|
||||
if metadata.RunID == "" {
|
||||
return "", fmt.Errorf("metadata run id is required")
|
||||
}
|
||||
if metadata.ModuleSnapshotPath == "" {
|
||||
return "", fmt.Errorf("metadata module snapshot path is required")
|
||||
}
|
||||
if metadata.DataPackagePath == "" {
|
||||
return "", fmt.Errorf("metadata data package path is required")
|
||||
}
|
||||
if metadata.PreflightPath == "" {
|
||||
return "", fmt.Errorf("metadata preflight path is required")
|
||||
}
|
||||
if metadata.MetadataPath == "" {
|
||||
return "", fmt.Errorf("metadata path is required")
|
||||
if err := metadata.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||
return "", err
|
||||
@@ -413,6 +433,34 @@ func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string
|
||||
return readJSON(path, target)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadPromptPreparation(_ context.Context, path string) (PromptPreparationArtifact, error) {
|
||||
if path == "" {
|
||||
return PromptPreparationArtifact{}, fmt.Errorf("prompt preparation path is required")
|
||||
}
|
||||
var artifact PromptPreparationArtifact
|
||||
if err := readJSON(path, &artifact); err != nil {
|
||||
return PromptPreparationArtifact{}, err
|
||||
}
|
||||
if err := artifact.Validate(); err != nil {
|
||||
return PromptPreparationArtifact{}, err
|
||||
}
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadPromptExecution(_ context.Context, path string) (PromptExecutionArtifact, error) {
|
||||
if path == "" {
|
||||
return PromptExecutionArtifact{}, fmt.Errorf("prompt execution path is required")
|
||||
}
|
||||
var artifact PromptExecutionArtifact
|
||||
if err := readJSON(path, &artifact); err != nil {
|
||||
return PromptExecutionArtifact{}, err
|
||||
}
|
||||
if err := artifact.Validate(); err != nil {
|
||||
return PromptExecutionArtifact{}, err
|
||||
}
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("render context path is required")
|
||||
@@ -428,6 +476,7 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
||||
if err := readJSON(path, &metadata); err != nil {
|
||||
return ReportRecord{}, err
|
||||
}
|
||||
metadata.MetadataPath = path
|
||||
return ReportRecord{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -30,6 +31,8 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"),
|
||||
filepath.Join("preflight", "daily", "2026-05-29", "prompt_preparation.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "prompt_execution.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"),
|
||||
@@ -40,6 +43,146 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptArtifactsAndV2MetadataRoundTrip(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
preparationPath, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{
|
||||
Status: PromptPreparationSucceeded,
|
||||
ReportID: metadata.ReportID,
|
||||
RunID: metadata.RunID,
|
||||
PromptID: metadata.PromptID,
|
||||
PromptVersion: "v1",
|
||||
DataPackagePath: paths.DataPackage,
|
||||
Preparation: &promptexec.Preparation{
|
||||
PromptID: metadata.PromptID, PromptVersion: "v1", PromptHash: "prompt-hash",
|
||||
DataPackagePath: paths.DataPackage,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePromptPreparation() error = %v", err)
|
||||
}
|
||||
executionPath, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{
|
||||
Status: PromptExecutionSucceeded,
|
||||
ReportID: metadata.ReportID,
|
||||
RunID: metadata.RunID,
|
||||
PromptID: metadata.PromptID,
|
||||
PromptVersion: "v1",
|
||||
Provenance: &PromptExecutionProvenance{
|
||||
RunID: metadata.RunID, PromptID: metadata.PromptID, PromptVersion: "v1",
|
||||
PromptHash: "prompt-hash", DataPackagePath: paths.DataPackage,
|
||||
},
|
||||
Validation: func() *promptexec.Validation {
|
||||
value := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "schemas/report.json", nil)
|
||||
return &value
|
||||
}(),
|
||||
Paths: PromptExecutionPaths{RawOutputPath: paths.GeneratedTextRaw, RenderedReportPath: paths.RenderedReport},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePromptExecution() error = %v", err)
|
||||
}
|
||||
loadedPreparation, err := store.LoadPromptPreparation(context.Background(), preparationPath)
|
||||
if err != nil || loadedPreparation.Preparation == nil || loadedPreparation.Preparation.PromptHash != "prompt-hash" {
|
||||
t.Fatalf("LoadPromptPreparation() = %#v, %v", loadedPreparation, err)
|
||||
}
|
||||
loadedExecution, err := store.LoadPromptExecution(context.Background(), executionPath)
|
||||
if err != nil || loadedExecution.Provenance == nil || loadedExecution.Validation == nil {
|
||||
t.Fatalf("LoadPromptExecution() = %#v, %v", loadedExecution, err)
|
||||
}
|
||||
executionData, err := os.ReadFile(executionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read execution artifact: %v", err)
|
||||
}
|
||||
if strings.Contains(string(executionData), `"RawOutput"`) || strings.Contains(string(executionData), `"Debug"`) {
|
||||
t.Fatalf("execution artifact contains sensitive content fields: %s", executionData)
|
||||
}
|
||||
|
||||
v2 := BuildPromptMetadataFromBriefingMetadata(resolved, stateBriefingMetadata(resolved), paths)
|
||||
v2.PreparationPath = preparationPath
|
||||
v2.ExecutionPath = executionPath
|
||||
metadataPath, err := store.SaveMetadata(context.Background(), v2)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read v2 metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") {
|
||||
t.Fatalf("v2 metadata contains deprecated aliases: %s", data)
|
||||
}
|
||||
loadedMetadata, _, err := store.LoadMetadataByRunID(context.Background(), metadata.RunID)
|
||||
if err != nil || loadedMetadata.PreparationPath != preparationPath || loadedMetadata.ExecutionPath != executionPath {
|
||||
t.Fatalf("LoadMetadataByRunID() = %#v, %v", loadedMetadata, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataV1CompatibilityAndUnknownVersion(t *testing.T) {
|
||||
legacy := Metadata{
|
||||
SchemaVersion: MetadataSchemaVersionV1,
|
||||
RunID: "legacy-run",
|
||||
MetadataPath: "/tmp/metadata.legacy-run.json",
|
||||
ReportID: report.Daily,
|
||||
PromptID: "weather.daily",
|
||||
ModuleSnapshotPath: "/tmp/modules.json",
|
||||
DataPackagePath: "/tmp/data.yaml",
|
||||
PreflightPath: "/tmp/render.json",
|
||||
GeneratedTextResultPath: "/tmp/generated-result.json",
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "preflightPath") || !strings.Contains(string(data), "generatedTextResultPath") || strings.Contains(string(data), "preparationPath") || strings.Contains(string(data), "executionPath") {
|
||||
t.Fatalf("legacy metadata JSON = %s", data)
|
||||
}
|
||||
var decoded Metadata
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if decoded.PreparationPath != legacy.PreflightPath || decoded.ExecutionPath != legacy.GeneratedTextResultPath {
|
||||
t.Fatalf("decoded compatibility paths = %#v", decoded)
|
||||
}
|
||||
remarshaled, err := json.Marshal(decoded)
|
||||
if err != nil || !strings.Contains(string(remarshaled), "preflightPath") || strings.Contains(string(remarshaled), "preparationPath") {
|
||||
t.Fatalf("remarshaled legacy metadata = %s, %v", remarshaled, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v99"}`), &decoded); err == nil {
|
||||
t.Fatal("Unmarshal() error = nil, want unsupported schema version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptArtifactRequiredFieldsAreRejected(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
if _, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{}); err == nil {
|
||||
t.Fatal("SavePromptPreparation() error = nil, want required-field error")
|
||||
}
|
||||
if _, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{}); err == nil {
|
||||
t.Fatal("SavePromptExecution() error = nil, want required-field error")
|
||||
}
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
if _, err := store.SaveMetadata(context.Background(), Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
ReportID: metadata.ReportID,
|
||||
PromptID: metadata.PromptID,
|
||||
ModuleSnapshotPath: paths.ModuleSnapshot,
|
||||
DataPackagePath: paths.DataPackage,
|
||||
}); err == nil {
|
||||
t.Fatal("SaveMetadata() error = nil, want v2 preparation-path error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00")
|
||||
@@ -960,6 +1103,8 @@ func pathsString(paths ArtifactPaths) string {
|
||||
paths.Metadata,
|
||||
paths.ModuleSnapshot,
|
||||
paths.DataPackage,
|
||||
paths.Preparation,
|
||||
paths.Execution,
|
||||
paths.Preflight,
|
||||
paths.Notification,
|
||||
paths.RenderedReport,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
@@ -9,12 +12,49 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
||||
const (
|
||||
MetadataSchemaVersionV1 = "weatherreporter.metadata.v1"
|
||||
MetadataSchemaVersion = "weatherreporter.metadata.v2"
|
||||
)
|
||||
|
||||
// Metadata is the durable record used for report discovery and inspection.
|
||||
// V1 fields remain internal compatibility values and are emitted only for V1
|
||||
// records; V2 records have no legacy aliases in their JSON representation.
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
PreparationPath string `json:"preparationPath,omitempty"`
|
||||
ExecutionPath string `json:"executionPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
|
||||
// These paths are retained only to preserve records written by the temporary
|
||||
// Scriptorium flow. They are never emitted in V2 metadata.
|
||||
PreflightPath string `json:"-"`
|
||||
GeneratedTextResultPath string `json:"-"`
|
||||
}
|
||||
|
||||
type metadataJSON struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
@@ -28,7 +68,9 @@ type Metadata struct {
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
PreflightPath string `json:"preflightPath"`
|
||||
PreparationPath string `json:"preparationPath,omitempty"`
|
||||
ExecutionPath string `json:"executionPath,omitempty"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
|
||||
@@ -38,10 +80,100 @@ type Metadata struct {
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
}
|
||||
|
||||
func (m Metadata) MarshalJSON() ([]byte, error) {
|
||||
w := metadataJSON{
|
||||
SchemaVersion: m.SchemaVersion, RunID: m.RunID, ReportID: m.ReportID,
|
||||
Variant: m.Variant, PromptID: m.PromptID, GeneratedAt: m.GeneratedAt,
|
||||
Timezone: m.Timezone, ValidPeriod: m.ValidPeriod, Location: m.Location,
|
||||
SourceLocationID: m.SourceLocationID, SourceLocation: m.SourceLocation,
|
||||
Sources: m.Sources, SourceWarnings: m.SourceWarnings,
|
||||
ModuleSnapshotPath: m.ModuleSnapshotPath, DataPackagePath: m.DataPackagePath,
|
||||
NotificationPath: m.NotificationPath, RenderedReportPath: m.RenderedReportPath,
|
||||
GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath,
|
||||
GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath,
|
||||
}
|
||||
switch m.SchemaVersion {
|
||||
case MetadataSchemaVersionV1:
|
||||
w.PreflightPath = m.PreflightPath
|
||||
w.GeneratedTextResultPath = m.GeneratedTextResultPath
|
||||
case MetadataSchemaVersion:
|
||||
w.PreparationPath = m.PreparationPath
|
||||
w.ExecutionPath = m.ExecutionPath
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion)
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
func (m *Metadata) UnmarshalJSON(data []byte) error {
|
||||
var header struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &header); err != nil {
|
||||
return err
|
||||
}
|
||||
if header.SchemaVersion != MetadataSchemaVersionV1 && header.SchemaVersion != MetadataSchemaVersion {
|
||||
return fmt.Errorf("unsupported metadata schema version %q", header.SchemaVersion)
|
||||
}
|
||||
var w metadataJSON
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = Metadata{
|
||||
SchemaVersion: w.SchemaVersion, RunID: w.RunID, ReportID: w.ReportID,
|
||||
Variant: w.Variant, PromptID: w.PromptID, GeneratedAt: w.GeneratedAt,
|
||||
Timezone: w.Timezone, ValidPeriod: w.ValidPeriod, Location: w.Location,
|
||||
SourceLocationID: w.SourceLocationID, SourceLocation: w.SourceLocation,
|
||||
Sources: w.Sources, SourceWarnings: w.SourceWarnings,
|
||||
ModuleSnapshotPath: w.ModuleSnapshotPath, DataPackagePath: w.DataPackagePath,
|
||||
NotificationPath: w.NotificationPath, RenderedReportPath: w.RenderedReportPath,
|
||||
GeneratedTextSchemaID: w.GeneratedTextSchemaID, GeneratedTextRawPath: w.GeneratedTextRawPath,
|
||||
GeneratedTextPath: w.GeneratedTextPath, RenderContextPath: w.RenderContextPath,
|
||||
}
|
||||
if w.SchemaVersion == MetadataSchemaVersionV1 {
|
||||
m.PreflightPath = w.PreflightPath
|
||||
m.GeneratedTextResultPath = w.GeneratedTextResultPath
|
||||
m.PreparationPath = w.PreflightPath
|
||||
m.ExecutionPath = w.GeneratedTextResultPath
|
||||
} else {
|
||||
m.PreparationPath = w.PreparationPath
|
||||
m.ExecutionPath = w.ExecutionPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Metadata) Validate() error {
|
||||
if strings.TrimSpace(m.RunID) == "" {
|
||||
return fmt.Errorf("metadata run id is required")
|
||||
}
|
||||
if strings.TrimSpace(m.ModuleSnapshotPath) == "" {
|
||||
return fmt.Errorf("metadata module snapshot path is required")
|
||||
}
|
||||
if strings.TrimSpace(m.DataPackagePath) == "" {
|
||||
return fmt.Errorf("metadata data package path is required")
|
||||
}
|
||||
if strings.TrimSpace(m.MetadataPath) == "" {
|
||||
return fmt.Errorf("metadata path is required")
|
||||
}
|
||||
switch m.SchemaVersion {
|
||||
case MetadataSchemaVersionV1:
|
||||
if strings.TrimSpace(m.PreflightPath) == "" {
|
||||
return fmt.Errorf("metadata preflight path is required")
|
||||
}
|
||||
case MetadataSchemaVersion:
|
||||
if strings.TrimSpace(m.PreparationPath) == "" {
|
||||
return fmt.Errorf("metadata preparation path is required")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||
metadata := resolved.Metadata()
|
||||
out := Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
SchemaVersion: MetadataSchemaVersionV1,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
ReportID: metadata.ReportID,
|
||||
@@ -68,6 +200,19 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildPromptMetadataFromBriefingMetadata creates the V2 record used by the
|
||||
// prompt execution workflow. Callers populate preparation and execution paths
|
||||
// only after their corresponding artifacts have been saved.
|
||||
func BuildPromptMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||
legacy := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, paths)
|
||||
legacy.SchemaVersion = MetadataSchemaVersion
|
||||
legacy.PreparationPath = ""
|
||||
legacy.ExecutionPath = ""
|
||||
legacy.PreflightPath = ""
|
||||
legacy.GeneratedTextResultPath = ""
|
||||
return legacy
|
||||
}
|
||||
|
||||
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
|
||||
191
internal/state/prompt_artifacts.go
Normal file
191
internal/state/prompt_artifacts.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
const (
|
||||
PromptPreparationSchemaVersion = "weatherreporter.prompt_preparation.v1"
|
||||
PromptExecutionSchemaVersion = "weatherreporter.prompt_execution.v1"
|
||||
promptArtifactErrorLimit = 2048
|
||||
)
|
||||
|
||||
type PromptPreparationStatus string
|
||||
|
||||
const (
|
||||
PromptPreparationSucceeded PromptPreparationStatus = "succeeded"
|
||||
PromptPreparationFailed PromptPreparationStatus = "failed"
|
||||
)
|
||||
|
||||
// PromptArtifactError is the bounded, classified failure detail retained with a
|
||||
// prompt execution artifact. It deliberately excludes provider error bodies.
|
||||
type PromptArtifactError struct {
|
||||
Category promptexec.ErrorCategory `json:"category"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewPromptArtifactError converts an execution error into bounded, durable
|
||||
// diagnostic information without retaining its underlying cause.
|
||||
func NewPromptArtifactError(err error) *PromptArtifactError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
message := strings.ToValidUTF8(err.Error(), "<22>")
|
||||
if len(message) > promptArtifactErrorLimit {
|
||||
message = message[:promptArtifactErrorLimit]
|
||||
for !utf8.ValidString(message) {
|
||||
message = message[:len(message)-1]
|
||||
}
|
||||
}
|
||||
return &PromptArtifactError{Category: promptexec.CategoryOf(err), Message: message}
|
||||
}
|
||||
|
||||
// PromptPreparationArtifact records the safe provenance available before a
|
||||
// provider is invoked. Preparation debug payloads are never stored here.
|
||||
type PromptPreparationArtifact struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Status PromptPreparationStatus `json:"status"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
RunID string `json:"runId"`
|
||||
PromptID string `json:"promptId"`
|
||||
PromptVersion string `json:"promptVersion,omitempty"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
Preparation *promptexec.Preparation `json:"preparation,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Error *PromptArtifactError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (a PromptPreparationArtifact) Validate() error {
|
||||
if a.SchemaVersion != PromptPreparationSchemaVersion {
|
||||
return fmt.Errorf("unsupported prompt preparation schema version %q", a.SchemaVersion)
|
||||
}
|
||||
if strings.TrimSpace(a.RunID) == "" || a.ReportID == "" || strings.TrimSpace(a.PromptID) == "" {
|
||||
return fmt.Errorf("prompt preparation identity is required")
|
||||
}
|
||||
if strings.TrimSpace(a.DataPackagePath) == "" {
|
||||
return fmt.Errorf("prompt preparation data package path is required")
|
||||
}
|
||||
switch a.Status {
|
||||
case PromptPreparationSucceeded:
|
||||
if a.Preparation == nil || a.Error != nil {
|
||||
return fmt.Errorf("successful prompt preparation requires preparation without an error")
|
||||
}
|
||||
case PromptPreparationFailed:
|
||||
if !validPromptArtifactError(a.Error) {
|
||||
return fmt.Errorf("failed prompt preparation requires a classified error")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported prompt preparation status %q", a.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PromptExecutionStatus string
|
||||
|
||||
const (
|
||||
PromptExecutionSucceeded PromptExecutionStatus = "succeeded"
|
||||
PromptExecutionValidationRejected PromptExecutionStatus = "validation_rejected"
|
||||
PromptExecutionFailed PromptExecutionStatus = "failed"
|
||||
)
|
||||
|
||||
// PromptExecutionProvenance is the safe subset of promptexec.Execution. The
|
||||
// generated content and debug payload are intentionally excluded.
|
||||
type PromptExecutionProvenance struct {
|
||||
RunID string `json:"runId"`
|
||||
PromptID string `json:"promptId"`
|
||||
PromptVersion string `json:"promptVersion"`
|
||||
PromptHash string `json:"promptHash"`
|
||||
RenderedPromptHash string `json:"renderedPromptHash"`
|
||||
InputHashes map[string]string `json:"inputHashes,omitempty"`
|
||||
ProfileID string `json:"profileId"`
|
||||
BackendID string `json:"backendId"`
|
||||
ModelName string `json:"modelName"`
|
||||
GeneratedHash string `json:"generatedHash,omitempty"`
|
||||
Usage promptexec.TokenUsage `json:"usage"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
}
|
||||
|
||||
// PromptExecutionPaths records only destinations reached by a completed run.
|
||||
// It contains paths, never generated content or debug information.
|
||||
type PromptExecutionPaths struct {
|
||||
RawOutputPath string `json:"rawOutputPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
}
|
||||
|
||||
// PromptExecutionArtifact records safe execution provenance and its validation
|
||||
// outcome. It never embeds generated output or content-rich debug data.
|
||||
type PromptExecutionArtifact struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Status PromptExecutionStatus `json:"status"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
RunID string `json:"runId"`
|
||||
PromptID string `json:"promptId"`
|
||||
PromptVersion string `json:"promptVersion,omitempty"`
|
||||
Provenance *PromptExecutionProvenance `json:"provenance,omitempty"`
|
||||
Validation *promptexec.Validation `json:"validation,omitempty"`
|
||||
Paths PromptExecutionPaths `json:"paths,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Error *PromptArtifactError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func PromptExecutionProvenanceFrom(value promptexec.Execution) PromptExecutionProvenance {
|
||||
inputHashes := make(map[string]string, len(value.InputHashes))
|
||||
for key, item := range value.InputHashes {
|
||||
inputHashes[key] = item
|
||||
}
|
||||
return PromptExecutionProvenance{
|
||||
RunID: value.RunID, PromptID: value.PromptID, PromptVersion: value.PromptVersion,
|
||||
PromptHash: value.PromptHash, RenderedPromptHash: value.RenderedPromptHash,
|
||||
InputHashes: inputHashes, ProfileID: value.ProfileID, BackendID: value.BackendID,
|
||||
ModelName: value.ModelName, GeneratedHash: value.GeneratedHash, Usage: value.Usage,
|
||||
StartedAt: value.StartedAt, EndedAt: value.EndedAt, Duration: value.Duration,
|
||||
DataPackagePath: value.DataPackagePath,
|
||||
}
|
||||
}
|
||||
|
||||
func (a PromptExecutionArtifact) Validate() error {
|
||||
if a.SchemaVersion != PromptExecutionSchemaVersion {
|
||||
return fmt.Errorf("unsupported prompt execution schema version %q", a.SchemaVersion)
|
||||
}
|
||||
if strings.TrimSpace(a.RunID) == "" || a.ReportID == "" || strings.TrimSpace(a.PromptID) == "" {
|
||||
return fmt.Errorf("prompt execution identity is required")
|
||||
}
|
||||
switch a.Status {
|
||||
case PromptExecutionSucceeded:
|
||||
if a.Provenance == nil || a.Validation == nil || a.Validation.Status != promptexec.ValidationPassed || a.Error != nil {
|
||||
return fmt.Errorf("successful prompt execution requires passed validation without an error")
|
||||
}
|
||||
case PromptExecutionValidationRejected:
|
||||
if a.Provenance == nil || a.Validation == nil || a.Validation.Status != promptexec.ValidationFailed || a.Error != nil {
|
||||
return fmt.Errorf("validation-rejected prompt execution requires failed validation without an error")
|
||||
}
|
||||
case PromptExecutionFailed:
|
||||
if !validPromptArtifactError(a.Error) {
|
||||
return fmt.Errorf("failed prompt execution requires a classified error")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported prompt execution status %q", a.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPromptArtifactError(value *PromptArtifactError) bool {
|
||||
return value != nil && value.Category != "" && strings.TrimSpace(value.Message) != "" && len(value.Message) <= promptArtifactErrorLimit && utf8.ValidString(value.Message)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ type Store interface {
|
||||
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
|
||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||
SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error)
|
||||
SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error)
|
||||
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
|
||||
SaveBatchDistributorNotification(context.Context, BatchDistributorNotificationRef, BatchDistributorNotificationArtifact) (string, error)
|
||||
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
|
||||
@@ -28,6 +30,8 @@ type Store interface {
|
||||
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
|
||||
LoadGeneratedText(context.Context, string) ([]byte, error)
|
||||
LoadGeneratedTextResult(context.Context, string, any) error
|
||||
LoadPromptPreparation(context.Context, string) (PromptPreparationArtifact, error)
|
||||
LoadPromptExecution(context.Context, string) (PromptExecutionArtifact, error)
|
||||
LoadRenderContext(context.Context, string, any) error
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user