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(), "�") 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 || a.Preparation.DataPackagePath != a.DataPackagePath { 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"` 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 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}, {"data package path", value.DataPackagePath}, } { 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 } }