package usecase import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/llm" "gitea.maximumdirect.net/eric/scriptorium/internal/profile" "gitea.maximumdirect.net/eric/scriptorium/internal/prompt" "gitea.maximumdirect.net/eric/scriptorium/internal/validate" ) var ( ErrInvalidRequest = errors.New("invalid run request") ErrProfileLoad = errors.New("failed to load profile") ErrArtifactLoad = errors.New("failed to load artifact") ErrPromptRender = errors.New("failed to render prompt") ErrLLMGenerate = errors.New("failed to generate output") ErrValidation = errors.New("failed to validate output") ) // Runner executes the Analyzer core use case. type Runner struct { profiles profile.Repository artifacts artifact.Reader renderer prompt.Renderer llm llm.Client validator validate.Validator } func NewRunner( profiles profile.Repository, artifacts artifact.Reader, renderer prompt.Renderer, llmClient llm.Client, validator validate.Validator, ) *Runner { return &Runner{ profiles: profiles, artifacts: artifacts, renderer: renderer, llm: llmClient, validator: validator, } } func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) { if strings.TrimSpace(req.ProfileID) == "" { return nil, fmt.Errorf("%w: profile id is required", ErrInvalidRequest) } start := time.Now().UTC() prof, err := r.profiles.GetProfile(ctx, req.ProfileID, req.ProfileVersion) if err != nil { return nil, fmt.Errorf("%w: %v", ErrProfileLoad, err) } effectiveModel := mergeModelTarget(prof.ModelDefaults, req.Model) effectiveContract := resolveOutputContract(prof, req.Validation) resolvedInputs := make(map[string]*domain.Artifact, len(req.Inputs)) inputHashes := make(map[string]string, len(req.Inputs)) for name, ref := range req.Inputs { art, readErr := r.artifacts.Read(ctx, ref) if readErr != nil { return nil, fmt.Errorf("%w: input %q: %v", ErrArtifactLoad, name, readErr) } if art.Name == "" { art.Name = name } resolvedInputs[name] = art inputHashes[name] = art.Hash } renderedPrompt, err := r.renderer.Render(ctx, prof, resolvedInputs, req.Vars) if err != nil { return nil, fmt.Errorf("%w: %v", ErrPromptRender, err) } promptHash := hashRenderedPrompt(*renderedPrompt) genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{ Prompt: *renderedPrompt, Target: effectiveModel, }) if err != nil { return nil, fmt.Errorf("%w: %v", ErrLLMGenerate, err) } outputArtifact := buildOutputArtifact(genResp.Content, effectiveContract.Format) validationResult := domain.ValidationResult{ Status: domain.ValidationSkipped, Mode: effectiveContract.ValidationMode, SchemaPath: effectiveContract.SchemaPath, RepairAttempts: effectiveContract.RepairAttempts, IsValid: true, } if r.validator != nil && effectiveContract.ValidationMode != domain.ValidationNone { validationResult, err = r.validator.Validate(ctx, &outputArtifact, effectiveContract) if err != nil { return nil, fmt.Errorf("%w: %v", ErrValidation, err) } } end := time.Now().UTC() return &domain.RunResult{ Artifact: outputArtifact, RawOutput: genResp.Content, Validation: validationResult, ProfileID: prof.ID, ProfileVersion: prof.Version, ModelName: effectiveModel.Model, Endpoint: effectiveModel.Endpoint, InputHashes: inputHashes, PromptHash: promptHash, Usage: genResp.Usage, StartTime: start, EndTime: end, }, nil } func mergeModelTarget(base domain.ModelTarget, override *domain.ModelTarget) domain.ModelTarget { if override == nil { return base } out := base if override.Endpoint != "" { out.Endpoint = override.Endpoint } if override.Model != "" { out.Model = override.Model } if override.Temperature != 0 { out.Temperature = override.Temperature } if override.MaxTokens != 0 { out.MaxTokens = override.MaxTokens } if override.TopP != 0 { out.TopP = override.TopP } return out } func resolveOutputContract(prof *domain.PromptProfile, override *domain.OutputContract) domain.OutputContract { contract := prof.Validation if contract.Format == "" { contract.Format = prof.OutputFormat } if override != nil { contract = *override } if contract.Format == "" { contract.Format = domain.FormatText } return contract } func hashRenderedPrompt(p domain.RenderedPrompt) string { var b strings.Builder for _, msg := range p.Messages { b.WriteString(msg.Role) b.WriteByte('\n') b.WriteString(msg.Content) b.WriteString("\n---\n") } h := sha256.Sum256([]byte(b.String())) return hex.EncodeToString(h[:]) } func buildOutputArtifact(content string, format domain.OutputFormat) domain.Artifact { body := []byte(content) hash := sha256.Sum256(body) contentType := "text/plain" switch format { case domain.FormatMarkdown: contentType = "text/markdown" case domain.FormatJSON: contentType = "application/json" } return domain.Artifact{ Name: "output", ContentType: contentType, Body: body, Size: int64(len(body)), Hash: hex.EncodeToString(hash[:]), } }