package pipeline import ( "context" "errors" "fmt" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) type validationOutcome string const ( validationApproved validationOutcome = "approved" validationRejected validationOutcome = "rejected" validationFailed validationOutcome = "failed" validationSkipped validationOutcome = "skipped" ) const correctionRequestIntroduction = "The previous response failed semantic validation. Return one complete corrected replacement response, not a patch, explanation, or commentary.\n\nCorrect all of the following:\n" // validationRecord captures the settled result of one configured validator. // Its fields remain private so reports cannot expose mutable warning storage. type validationRecord struct { validatorName string outcome validationOutcome attemptCount int reasonCode string message string diagnosticPath string warnings []contracts.Warning correctionGuidance string failure error } func (record validationRecord) clone() validationRecord { record.warnings = cloneWarnings(record.warnings) return record } // validationReport is the immutable, ordered result of one validator chain. type validationReport struct { records []validationRecord } func (report validationReport) Records() []validationRecord { records := make([]validationRecord, len(report.records)) for index, record := range report.records { records[index] = record.clone() } return records } func (report validationReport) Warnings() []contracts.Warning { var warnings []contracts.Warning for _, record := range report.records { if record.outcome == validationApproved || record.outcome == validationRejected { warnings = append(warnings, cloneWarnings(record.warnings)...) } } return warnings } func (report validationReport) FirstRejection() *validationRecord { for _, record := range report.records { if record.outcome == validationRejected { clone := record.clone() return &clone } } return nil } func (report validationReport) FirstFailure() *validationRecord { for _, record := range report.records { if record.outcome == validationFailed { clone := record.clone() return &clone } } return nil } func (report validationReport) CorrectionRequest() (string, error) { seen := make(map[string]struct{}) parts := make([]string, 0, len(report.records)) for _, record := range report.records { if record.outcome != validationRejected { continue } guidance := strings.TrimSpace(record.correctionGuidance) if guidance == "" { return "", fmt.Errorf("validator %q rejected output without correction guidance", record.validatorName) } if _, exists := seen[guidance]; exists { continue } seen[guidance] = struct{}{} parts = append(parts, guidance) } if len(parts) == 0 { return "", errors.New("validation report contains no correction guidance") } var builder strings.Builder builder.WriteString(correctionRequestIntroduction) for index, guidance := range parts { fmt.Fprintf(&builder, "%d. %s", index+1, guidance) if index+1 < len(parts) { builder.WriteByte('\n') } } request := builder.String() if len(request) > contracts.MaxCorrectionGuidanceBytes { return "", fmt.Errorf("aggregate correction request exceeds maximum length of %d bytes", contracts.MaxCorrectionGuidanceBytes) } return request, nil } type validationInvocation struct { result contracts.ValidationResult skipped bool reason string message string } func skippedValidation(reason, message string) validationInvocation { return validationInvocation{skipped: true, reason: strings.TrimSpace(reason), message: strings.TrimSpace(message)} } type validationInvoker func(context.Context, preparedValidator, int) (validationInvocation, error) type validationFrameworkError struct{ err error } func (err validationFrameworkError) Error() string { return err.err.Error() } func (err validationFrameworkError) Unwrap() error { return err.err } func fatalValidationError(err error) error { if err == nil { return nil } return validationFrameworkError{err: err} } func executeValidationChain(ctx context.Context, chain preparedValidatorChain, invoke validationInvoker) (validationReport, error) { if ctx == nil { return validationReport{}, errors.New("validator execution context must not be nil") } if invoke == nil { return validationReport{}, errors.New("validator invocation must not be nil") } report := validationReport{records: make([]validationRecord, 0, len(chain.validators))} for index, validator := range chain.validators { validator.position = index + 1 if err := ctx.Err(); err != nil { return validationReport{}, err } binding := validator.resolved.Binding attemptLimit := 1 if validator.resolved.ExecutionClass == contracts.ExecutionClassLLMBacked { attemptLimit += binding.Retries } for attempt := 1; attempt <= attemptLimit; attempt++ { if err := ctx.Err(); err != nil { return validationReport{}, err } invocation, err := invoke(ctx, validator, attempt) if err != nil { var frameworkErr validationFrameworkError if errors.As(err, &frameworkErr) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return validationReport{}, err } if attempt == attemptLimit { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator failed after retry exhaustion", failure: err}) } continue } if invocation.skipped { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message}) break } if err := contracts.ValidateValidationResult(invocation.result); err != nil { if attempt == attemptLimit { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err}) } continue } if invocation.result.Approved { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationApproved, attemptCount: attempt, warnings: cloneWarnings(invocation.result.Warnings), diagnosticPath: invocation.result.DiagnosticArtifactPath}) break } message := invocation.result.Message if message == "" { message = "output rejected" } report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationRejected, attemptCount: attempt, reasonCode: invocation.result.ReasonCode, message: message, diagnosticPath: invocation.result.DiagnosticArtifactPath, warnings: cloneWarnings(invocation.result.Warnings), correctionGuidance: invocation.result.CorrectionGuidance}) break } } return report, nil } func validatorFailureError(record validationRecord) error { if record.failure != nil { return record.failure } return fmt.Errorf("validator %q failed after %d attempt(s)", record.validatorName, record.attemptCount) } func validatorAttemptPath(base string, attempt int) string { if attempt == 1 { return base } return fmt.Sprintf("%s-validator-%02d", base, attempt) }