Added support for OpenAI-compatible structured output

This commit is contained in:
2026-05-08 07:32:54 -05:00
parent b52e3252f3
commit f3e8c960af
11 changed files with 493 additions and 25 deletions

View File

@@ -11,6 +11,7 @@ import (
"os"
"strings"
"time"
"unicode"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
@@ -87,8 +88,9 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
}
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: prepared.Messages},
Target: prepared.EffectiveModelParams,
Prompt: domain.RenderedPrompt{Messages: prepared.Messages},
Target: prepared.EffectiveModelParams,
StructuredOutput: prepared.StructuredOutput,
})
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err)
@@ -109,6 +111,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
PreviousOutput: genResp.Content,
ValidationErrors: validationResult.Errors,
Target: prepared.EffectiveModelParams,
StructuredOutput: prepared.StructuredOutput,
Attempt: attemptsUsed,
MaxAttempts: prepared.OutputContract.RepairAttempts,
Mode: prepared.OutputContract.ValidationMode,
@@ -194,6 +197,10 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
}
effectiveContract := resolveOutputContract(def, req.Validation)
structuredOutput, err := r.resolveStructuredOutput(ctx, def, effectiveContract)
if err != nil {
return nil, err
}
resolvedInputs := make(map[string]*domain.Artifact, len(req.Inputs))
inputHashes := make(map[string]string, len(req.Inputs))
@@ -222,6 +229,7 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
SelectedProfileID: selectedProfileID,
EffectiveModelParams: effectiveModel,
OutputContract: effectiveContract,
StructuredOutput: structuredOutput,
InputHashes: inputHashes,
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
Messages: renderedPrompt.Messages,
@@ -231,6 +239,57 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
}, nil
}
func (r *Runner) resolveStructuredOutput(ctx context.Context, def *domain.PromptDefinition, contract domain.OutputContract) (*domain.StructuredOutputSpec, error) {
if contract.ValidationMode != domain.ValidationJSONSchema {
return nil, nil
}
loader, ok := r.validator.(validate.SchemaDocumentLoader)
if !ok || loader == nil {
return nil, fmt.Errorf("%w: json_schema output requires schema document loader", ErrValidation)
}
schemaDoc, err := loader.LoadSchemaDocument(ctx, contract.SchemaPath)
if err != nil {
return nil, fmt.Errorf("%w: failed to load json schema for structured output: %v", ErrValidation, err)
}
return &domain.StructuredOutputSpec{
Type: domain.StructuredOutputJSONSchema,
JSONSchema: &domain.StructuredOutputJSONSpec{
Name: deriveStructuredSchemaName(def.ID, def.Version),
Strict: true,
Schema: schemaDoc,
},
}, nil
}
func deriveStructuredSchemaName(promptID string, promptVersion string) string {
raw := strings.TrimSpace(promptID)
if v := strings.TrimSpace(promptVersion); v != "" {
if raw == "" {
raw = v
} else {
raw = raw + "_" + v
}
}
var b strings.Builder
for _, r := range raw {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
name := strings.Trim(b.String(), "_-")
if name == "" {
return "scriptorium_schema"
}
return name
}
func (r *Runner) validateOutput(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract, attemptsUsed int) (domain.ValidationResult, error) {
if r.validator == nil || contract.ValidationMode == domain.ValidationNone {
return domain.ValidationResult{