Files
weatherreporter/internal/state/prompt_artifacts.go

279 lines
12 KiB
Go
Raw Blame History

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 err := validatePromptArtifactIdentity("prompt preparation", a.ReportID, a.RunID, a.PromptID, a.PromptVersion); err != nil {
return err
}
if strings.TrimSpace(a.DataPackagePath) == "" {
return fmt.Errorf("prompt preparation data package path is required")
}
if err := validatePromptArtifactTiming("prompt preparation", a.StartedAt, a.EndedAt, a.Duration); err != nil {
return err
}
switch a.Status {
case PromptPreparationSucceeded:
if a.Preparation == nil || a.Error != nil {
return fmt.Errorf("successful prompt preparation requires preparation without an error")
}
if a.Preparation.PromptID != a.PromptID || a.Preparation.PromptVersion != a.PromptVersion {
return fmt.Errorf("successful prompt preparation provenance must match the artifact")
}
case PromptPreparationFailed:
if !validPromptArtifactError(a.Error) {
return fmt.Errorf("failed prompt preparation requires a classified error")
}
if a.Preparation != nil {
return fmt.Errorf("failed prompt preparation must not include preparation provenance")
}
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"`
}
// 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,
}
}
func (a PromptExecutionArtifact) Validate() error {
if a.SchemaVersion != PromptExecutionSchemaVersion {
return fmt.Errorf("unsupported prompt execution schema version %q", a.SchemaVersion)
}
if err := validatePromptArtifactIdentity("prompt execution", a.ReportID, a.RunID, a.PromptID, a.PromptVersion); err != nil {
return err
}
if err := validatePromptArtifactTiming("prompt execution", a.StartedAt, a.EndedAt, a.Duration); err != nil {
return err
}
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")
}
if err := validatePromptExecutionProvenance(a); err != nil {
return err
}
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")
}
if err := validatePromptExecutionProvenance(a); err != nil {
return err
}
case PromptExecutionFailed:
if !validPromptArtifactError(a.Error) {
return fmt.Errorf("failed prompt execution requires a classified error")
}
if a.Provenance != nil || a.Validation != nil {
return fmt.Errorf("failed prompt execution must not include completed provenance or validation")
}
default:
return fmt.Errorf("unsupported prompt execution status %q", a.Status)
}
return nil
}
func validPromptArtifactError(value *PromptArtifactError) bool {
return value != nil && validPromptErrorCategory(value.Category) && strings.TrimSpace(value.Message) != "" && len(value.Message) <= promptArtifactErrorLimit && utf8.ValidString(value.Message)
}
func validatePromptArtifactIdentity(kind string, reportID report.ID, runID, promptID, promptVersion string) error {
if reportID == "" || strings.TrimSpace(runID) == "" || strings.TrimSpace(promptID) == "" {
return fmt.Errorf("%s identity is required", kind)
}
definition, err := report.DefaultRegistry().Lookup(reportID)
if err != nil {
return fmt.Errorf("%s report id is unsupported: %w", kind, err)
}
if promptID != definition.PromptID {
return fmt.Errorf("%s prompt id must match report %q", kind, reportID)
}
if promptVersion != definition.PromptVersion {
return fmt.Errorf("%s prompt version must be %q", kind, definition.PromptVersion)
}
return nil
}
func validatePromptArtifactTiming(kind string, startedAt, endedAt time.Time, duration time.Duration) error {
if startedAt.IsZero() || endedAt.IsZero() {
return fmt.Errorf("%s start and end times are required", kind)
}
if duration < 0 {
return fmt.Errorf("%s duration must not be negative", kind)
}
if endedAt.Before(startedAt) {
return fmt.Errorf("%s end time must not be earlier than its start time", kind)
}
return nil
}
func validatePromptExecutionProvenance(artifact PromptExecutionArtifact) error {
value := artifact.Provenance
if value == nil {
return fmt.Errorf("completed prompt execution provenance is required")
}
if value.PromptID != artifact.PromptID || value.PromptVersion != artifact.PromptVersion {
return fmt.Errorf("completed prompt execution provenance must match the artifact")
}
for _, required := range []struct {
name string
value string
}{
{"run id", value.RunID}, {"prompt hash", value.PromptHash}, {"rendered prompt hash", value.RenderedPromptHash},
{"profile id", value.ProfileID}, {"backend id", value.BackendID}, {"model name", value.ModelName},
} {
if strings.TrimSpace(required.value) == "" {
return fmt.Errorf("completed prompt execution provenance %s is required", required.name)
}
}
if err := validatePromptArtifactTiming("completed prompt execution provenance", value.StartedAt, value.EndedAt, value.Duration); err != nil {
return err
}
return nil
}
func validPromptErrorCategory(category promptexec.ErrorCategory) bool {
switch category {
case promptexec.InvalidConfiguration, promptexec.InvalidRequest, promptexec.PromptNotFound,
promptexec.PromptLoad, promptexec.ProfileNotFound, promptexec.ProfileLoad,
promptexec.MissingCredential, promptexec.ArtifactLoad, promptexec.PromptRender,
promptexec.Capacity, promptexec.Generation, promptexec.OperationalValidation,
promptexec.ValidationRejected, promptexec.Canceled, promptexec.DeadlineExceeded:
return true
default:
return false
}
}