diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..4a888aa --- /dev/null +++ b/convert.go @@ -0,0 +1,274 @@ +package scriptorium + +import "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + +func toDomainRunRequest(req RunRequest) domain.RunRequest { + return domain.RunRequest{ + PromptID: req.PromptID, + PromptVersion: req.PromptVersion, + ProfileID: req.ProfileID, + Inputs: toDomainArtifactRefMap(req.Inputs), + Vars: copyStringMap(req.Vars), + Execution: toDomainExecutionTargetOverride(req.Execution), + Validation: toDomainOutputContractPtr(req.Validation), + Metadata: copyStringMap(req.Metadata), + } +} + +func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun { + if prepared == nil { + return nil + } + return &PreparedRun{ + PromptID: prepared.PromptID, + PromptVersion: prepared.PromptVersion, + PromptHash: prepared.PromptHash, + SelectedProfileID: prepared.SelectedProfileID, + EffectiveModelParams: fromDomainExecutionTarget(prepared.EffectiveModelParams), + OutputContract: fromDomainOutputContract(prepared.OutputContract), + StructuredOutput: fromDomainStructuredOutputSpec(prepared.StructuredOutput), + InputHashes: copyStringMap(prepared.InputHashes), + SessionID: prepared.SessionID, + RenderedPromptHash: prepared.RenderedPromptHash, + Messages: fromDomainRenderedMessages(prepared.Messages), + StartTime: prepared.StartTime, + EndTime: prepared.EndTime, + DurationMS: prepared.DurationMS, + } +} + +func toDomainArtifactRefMap(src map[string]ArtifactRef) map[string]domain.ArtifactRef { + if src == nil { + return nil + } + out := make(map[string]domain.ArtifactRef, len(src)) + for k, v := range src { + out[k] = toDomainArtifactRef(v) + } + return out +} + +func toDomainArtifactRef(ref ArtifactRef) domain.ArtifactRef { + return domain.ArtifactRef{ + Type: domain.ArtifactRefType(ref.Type), + URI: ref.URI, + Body: ref.Body, + } +} + +func fromDomainArtifact(artifact domain.Artifact) Artifact { + return Artifact{ + Name: artifact.Name, + ContentType: artifact.ContentType, + Body: copyBytes(artifact.Body), + URI: artifact.URI, + Size: artifact.Size, + Hash: artifact.Hash, + } +} + +func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) *domain.ExecutionTargetOverride { + if override == nil { + return nil + } + return &domain.ExecutionTargetOverride{ + Endpoint: override.Endpoint, + Model: override.Model, + Temperature: copyFloat64Ptr(override.Temperature), + MaxTokens: copyIntPtr(override.MaxTokens), + TopP: copyFloat64Ptr(override.TopP), + TimeoutSeconds: copyIntPtr(override.TimeoutSeconds), + ServiceTier: override.ServiceTier, + ReasoningEffort: override.ReasoningEffort, + APIKeyEnv: override.APIKeyEnv, + ExtraParams: copyAnyMap(override.ExtraParams), + } +} + +func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget { + return ExecutionTarget{ + Endpoint: target.Endpoint, + Model: target.Model, + Temperature: target.Temperature, + MaxTokens: target.MaxTokens, + TopP: target.TopP, + TimeoutSeconds: target.TimeoutSeconds, + ServiceTier: target.ServiceTier, + ReasoningEffort: target.ReasoningEffort, + APIKeyEnv: target.APIKeyEnv, + ExtraParams: copyAnyMap(target.ExtraParams), + } +} + +func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence { + return ExecutionTargetPresence{ + Temperature: presence.Temperature, + MaxTokens: presence.MaxTokens, + TopP: presence.TopP, + TimeoutSeconds: presence.TimeoutSeconds, + } +} + +func toDomainOutputContractPtr(contract *OutputContract) *domain.OutputContract { + if contract == nil { + return nil + } + out := toDomainOutputContract(*contract) + return &out +} + +func toDomainOutputContract(contract OutputContract) domain.OutputContract { + return domain.OutputContract{ + Format: domain.OutputFormat(contract.Format), + ValidationMode: domain.ValidationMode(contract.ValidationMode), + SchemaPath: contract.SchemaPath, + RepairAttempts: contract.RepairAttempts, + } +} + +func fromDomainOutputContract(contract domain.OutputContract) OutputContract { + return OutputContract{ + Format: OutputFormat(contract.Format), + ValidationMode: ValidationMode(contract.ValidationMode), + SchemaPath: contract.SchemaPath, + RepairAttempts: contract.RepairAttempts, + } +} + +func fromDomainValidationResult(result domain.ValidationResult) ValidationResult { + return ValidationResult{ + Status: ValidationStatus(result.Status), + Mode: ValidationMode(result.Mode), + Errors: copyStringSlice(result.Errors), + SchemaPath: result.SchemaPath, + RepairAttempts: result.RepairAttempts, + IsValid: result.IsValid, + } +} + +func fromDomainTokenUsage(usage domain.TokenUsage) TokenUsage { + return TokenUsage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CachedTokens: usage.CachedTokens, + CacheWriteTokens: usage.CacheWriteTokens, + } +} + +func fromDomainRenderedMessages(messages []domain.RenderedMessage) []RenderedMessage { + if messages == nil { + return nil + } + out := make([]RenderedMessage, len(messages)) + for i, msg := range messages { + out[i] = RenderedMessage{ + Role: msg.Role, + Content: msg.Content, + CacheControl: fromDomainCacheControl(msg.CacheControl), + } + } + return out +} + +func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl { + if cacheControl == nil { + return nil + } + return &CacheControl{ + Type: CacheControlType(cacheControl.Type), + TTL: cacheControl.TTL, + } +} + +func fromDomainStructuredOutputSpec(spec *domain.StructuredOutputSpec) *StructuredOutputSpec { + if spec == nil { + return nil + } + out := &StructuredOutputSpec{ + Type: StructuredOutputType(spec.Type), + } + if spec.JSONSchema != nil { + out.JSONSchema = &StructuredOutputJSONSpec{ + Name: spec.JSONSchema.Name, + Strict: spec.JSONSchema.Strict, + Schema: copyAny(spec.JSONSchema.Schema), + } + } + return out +} + +func copyStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + +func copyAnyMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + out := make(map[string]any, len(src)) + for k, v := range src { + out[k] = copyAny(v) + } + return out +} + +func copyAny(value any) any { + switch v := value.(type) { + case map[string]any: + return copyAnyMap(v) + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = copyAny(item) + } + return out + case []string: + return copyStringSlice(v) + case []byte: + return copyBytes(v) + default: + return value + } +} + +func copyStringSlice(src []string) []string { + if src == nil { + return nil + } + out := make([]string, len(src)) + copy(out, src) + return out +} + +func copyBytes(src []byte) []byte { + if src == nil { + return nil + } + out := make([]byte, len(src)) + copy(out, src) + return out +} + +func copyFloat64Ptr(src *float64) *float64 { + if src == nil { + return nil + } + v := *src + return &v +} + +func copyIntPtr(src *int) *int { + if src == nil { + return nil + } + v := *src + return &v +} diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..8353e7c --- /dev/null +++ b/engine.go @@ -0,0 +1,99 @@ +package scriptorium + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" + "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" + "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/promptdef" + "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" + "gitea.maximumdirect.net/eric/scriptorium/internal/validate" +) + +// ErrInvalidConfig indicates invalid public engine configuration. +var ErrInvalidConfig = errors.New("invalid engine configuration") + +// Engine prepares Scriptorium prompt requests. +type Engine struct { + runner *usecase.Runner +} + +// Config configures a public Scriptorium engine. +type Config struct { + PromptDir string + ProfileDir string + SchemaDir string + Timeout time.Duration + HTTPClient *http.Client +} + +// Option customizes engine construction. +type Option func(*engineOptions) error + +type engineOptions struct{} + +// NewEngine constructs an Engine using the same default internal components as +// the CLI and HTTP adapters. +func NewEngine(cfg Config, opts ...Option) (*Engine, error) { + if strings.TrimSpace(cfg.PromptDir) == "" { + return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig) + } + if strings.TrimSpace(cfg.ProfileDir) == "" { + return nil, fmt.Errorf("%w: profile directory is required", ErrInvalidConfig) + } + + var options engineOptions + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(&options); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + } + + schemaDir := cfg.SchemaDir + if strings.TrimSpace(schemaDir) == "" { + schemaDir = defaults.SchemaDirDefault + } + + llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ + Timeout: cfg.Timeout, + HTTPClient: cfg.HTTPClient, + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + return &Engine{ + runner: usecase.NewRunner( + promptdef.NewFilesystemRepository(cfg.PromptDir), + profile.NewFilesystemRepository(cfg.ProfileDir), + artifactadapter.NewCompositeReader(), + prompt.NewGoRenderer(), + llmClient, + validate.NewStandardValidator(schemaDir), + ), + }, nil +} + +// Prepare resolves a prompt request without calling an LLM. +func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) { + if e == nil || e.runner == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + + prepared, err := e.runner.Prepare(ctx, toDomainRunRequest(req)) + if err != nil { + return nil, err + } + return fromDomainPreparedRun(prepared), nil +} diff --git a/engine_test.go b/engine_test.go new file mode 100644 index 0000000..682820a --- /dev/null +++ b/engine_test.go @@ -0,0 +1,164 @@ +package scriptorium_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/scriptorium" +) + +func TestNewEngineRejectsMissingPromptDir(t *testing.T) { + _, err := scriptorium.NewEngine(scriptorium.Config{ProfileDir: "./examples/profiles"}) + if !errors.Is(err, scriptorium.ErrInvalidConfig) { + t.Fatalf("expected ErrInvalidConfig, got %v", err) + } +} + +func TestNewEngineRejectsMissingProfileDir(t *testing.T) { + _, err := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"}) + if !errors.Is(err, scriptorium.ErrInvalidConfig) { + t.Fatalf("expected ErrInvalidConfig, got %v", err) + } +} + +func TestPrepareWorksWithExampleDirectoriesAndFileInputs(t *testing.T) { + engine := newExampleEngine(t) + + prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.File("./examples/fixtures/transcript.md"), + "glossary": scriptorium.File("./examples/fixtures/glossary.yml"), + }, + }) + if err != nil { + t.Fatalf("expected prepare to succeed, got %v", err) + } + if prepared.PromptID != "generic.markdown_summary" { + t.Fatalf("unexpected prompt id: %q", prepared.PromptID) + } + if prepared.SelectedProfileID != "local-fast" { + t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID) + } + if prepared.EffectiveModelParams.Model != "gpt-4o-mini" { + t.Fatalf("unexpected effective model: %q", prepared.EffectiveModelParams.Model) + } + if len(prepared.Messages) != 2 { + t.Fatalf("expected rendered messages, got %d", len(prepared.Messages)) + } + if prepared.InputHashes["transcript"] == "" || prepared.InputHashes["glossary"] == "" { + t.Fatalf("expected input hashes, got %#v", prepared.InputHashes) + } +} + +func TestPrepareWorksWithInlineInputs(t *testing.T) { + engine := newExampleEngine(t) + + prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.Inline("Rin scouts the tower.\nKara lights a lantern."), + "glossary": scriptorium.InlineWithURI("memory://glossary.yml", "party:\n - Rin\n - Kara\n"), + }, + }) + if err != nil { + t.Fatalf("expected prepare to succeed, got %v", err) + } + if len(prepared.Messages) != 2 { + t.Fatalf("expected rendered messages, got %d", len(prepared.Messages)) + } + rendered := prepared.Messages[1].Content + if !strings.Contains(rendered, "Rin scouts the tower.") || !strings.Contains(rendered, "party:") { + t.Fatalf("expected inline inputs in rendered prompt, got %q", rendered) + } +} + +func TestPreparedRunJSONDoesNotExposeSecretOrTargetPresence(t *testing.T) { + const envName = "SCRIPTORIUM_API_KEY" + const secret = "public-api-test-secret" + t.Setenv(envName, secret) + + engine := newExampleEngine(t) + prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.structured_events", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.File("./examples/fixtures/transcript.md"), + "glossary": scriptorium.File("./examples/fixtures/glossary.yml"), + }, + }) + if err != nil { + t.Fatalf("expected prepare to succeed, got %v", err) + } + + payload, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("expected prepared run to marshal, got %v", err) + } + out := string(payload) + if strings.Contains(out, secret) { + t.Fatalf("prepared run JSON leaked raw API key value: %s", out) + } + if !strings.Contains(out, envName) { + t.Fatalf("prepared run JSON should retain api_key_env name, got %s", out) + } + for _, forbidden := range []string{"TargetPresence", "target_presence"} { + if strings.Contains(out, forbidden) { + t.Fatalf("prepared run JSON exposed internal target presence metadata %q: %s", forbidden, out) + } + } +} + +func TestPreparePreservesExplicitZeroExecutionOverrides(t *testing.T) { + engine := newExampleEngine(t) + zeroFloat := 0.0 + zeroInt := 0 + + prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.File("./examples/fixtures/transcript.md"), + "glossary": scriptorium.File("./examples/fixtures/glossary.yml"), + }, + Execution: &scriptorium.ExecutionTargetOverride{ + Temperature: &zeroFloat, + MaxTokens: &zeroInt, + TopP: &zeroFloat, + TimeoutSeconds: &zeroInt, + }, + }) + if err != nil { + t.Fatalf("expected prepare to succeed, got %v", err) + } + target := prepared.EffectiveModelParams + if target.Temperature != 0 || target.MaxTokens != 0 || target.TopP != 0 || target.TimeoutSeconds != 0 { + t.Fatalf("expected explicit zero overrides in effective target, got %+v", target) + } +} + +func newExampleEngine(t *testing.T) *scriptorium.Engine { + t.Helper() + + for _, path := range []string{ + "./examples/prompts", + "./examples/profiles", + "./examples/schemas", + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected example path %s to exist: %v", path, err) + } + } + + engine, err := scriptorium.NewEngine(scriptorium.Config{ + PromptDir: "./examples/prompts", + ProfileDir: "./examples/profiles", + SchemaDir: "./examples/schemas", + }) + if err != nil { + t.Fatalf("expected engine construction to succeed, got %v", err) + } + return engine +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..d548757 --- /dev/null +++ b/types.go @@ -0,0 +1,207 @@ +package scriptorium + +import "time" + +// ArtifactRefType defines how an artifact is referenced. +type ArtifactRefType string + +const ( + ArtifactRefInline ArtifactRefType = "inline" + ArtifactRefFile ArtifactRefType = "file" +) + +// OutputFormat defines the desired output format. +type OutputFormat string + +const ( + FormatText OutputFormat = "text" + FormatMarkdown OutputFormat = "markdown" + FormatJSON OutputFormat = "json" +) + +// ValidationMode defines the output validation strategy. +type ValidationMode string + +const ( + ValidationNone ValidationMode = "none" + ValidationBasic ValidationMode = "basic" + ValidationJSON ValidationMode = "json" + ValidationJSONSchema ValidationMode = "json_schema" +) + +// ValidationStatus defines the result of a validation check. +type ValidationStatus string + +const ( + ValidationPassed ValidationStatus = "passed" + ValidationFailed ValidationStatus = "failed" + ValidationSkipped ValidationStatus = "skipped" +) + +// CacheControlType defines provider cache behavior for prompt content. +type CacheControlType string + +const ( + CacheControlEphemeral CacheControlType = "ephemeral" +) + +// StructuredOutputType identifies provider-level structured output modes. +type StructuredOutputType string + +const ( + StructuredOutputJSONSchema StructuredOutputType = "json_schema" +) + +// RunRequest represents a request to prepare a single prompt. +type RunRequest struct { + PromptID string + PromptVersion string + ProfileID string + Inputs map[string]ArtifactRef + Vars map[string]string + Execution *ExecutionTargetOverride + Validation *OutputContract + Metadata map[string]string +} + +// PreparedRun contains prepared prompt execution state. It does not include +// resolved API key values, model output, validation results, or internal target +// presence metadata. +type PreparedRun struct { + PromptID string `json:"prompt_id"` + PromptVersion string `json:"prompt_version,omitempty"` + PromptHash string `json:"prompt_hash,omitempty"` + SelectedProfileID string `json:"selected_profile_id"` + EffectiveModelParams ExecutionTarget `json:"effective_model_params"` + OutputContract OutputContract `json:"output_contract"` + StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` + InputHashes map[string]string `json:"input_hashes,omitempty"` + SessionID string `json:"session_id,omitempty"` + RenderedPromptHash string `json:"rendered_prompt_hash"` + Messages []RenderedMessage `json:"messages"` + StartTime time.Time `json:"start_time,omitempty"` + EndTime time.Time `json:"end_time,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// ArtifactRef represents a reference to prompt input content. +type ArtifactRef struct { + Type ArtifactRefType + URI string + Body string +} + +// Artifact represents loaded artifact content. +type Artifact struct { + Name string + ContentType string + Body []byte + URI string + Size int64 + Hash string +} + +// ExecutionTarget represents effective model runtime settings. +type ExecutionTarget struct { + Endpoint string `json:"endpoint"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + TopP float64 `json:"top_p"` + TimeoutSeconds int `json:"timeout_seconds"` + ServiceTier string `json:"service_tier"` + ReasoningEffort string `json:"reasoning_effort"` + APIKeyEnv string `json:"api_key_env"` + ExtraParams map[string]any `json:"extra_params"` +} + +// ExecutionTargetOverride represents per-request runtime setting overrides. +type ExecutionTargetOverride struct { + Endpoint string + Model string + Temperature *float64 + MaxTokens *int + TopP *float64 + TimeoutSeconds *int + ServiceTier string + ReasoningEffort string + APIKeyEnv string + ExtraParams map[string]any +} + +// ExecutionTargetPresence tracks which numeric runtime settings were explicit +// request overrides. +type ExecutionTargetPresence struct { + Temperature bool + MaxTokens bool + TopP bool + TimeoutSeconds bool +} + +// OutputContract defines output and validation requirements. +type OutputContract struct { + Format OutputFormat `json:"format"` + ValidationMode ValidationMode `json:"validation_mode"` + SchemaPath string `json:"schema_path"` + RepairAttempts int `json:"repair_attempts"` +} + +// ValidationResult represents output validation state. +type ValidationResult struct { + Status ValidationStatus + Mode ValidationMode + Errors []string + SchemaPath string + RepairAttempts int + IsValid bool +} + +// TokenUsage tracks token consumption. +type TokenUsage struct { + PromptTokens int + CompletionTokens int + TotalTokens int + CachedTokens int + CacheWriteTokens int +} + +// RenderedMessage is a rendered chat message. +type RenderedMessage struct { + Role string `json:"role"` + Content string `json:"content"` + CacheControl *CacheControl `json:"cache_control,omitempty"` +} + +// CacheControl describes provider cache metadata attached to prompt content. +type CacheControl struct { + Type CacheControlType `json:"type"` + TTL string `json:"ttl,omitempty"` +} + +// StructuredOutputSpec describes provider-level structured output. +type StructuredOutputSpec struct { + Type StructuredOutputType `json:"type"` + JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"` +} + +// StructuredOutputJSONSpec contains JSON Schema output constraints. +type StructuredOutputJSONSpec struct { + Name string `json:"name"` + Strict bool `json:"strict"` + Schema any `json:"schema"` +} + +// File returns a file-backed artifact reference. +func File(path string) ArtifactRef { + return ArtifactRef{Type: ArtifactRefFile, URI: path} +} + +// Inline returns an inline artifact reference. +func Inline(body string) ArtifactRef { + return ArtifactRef{Type: ArtifactRefInline, Body: body} +} + +// InlineWithURI returns an inline artifact reference with URI metadata. +func InlineWithURI(uri string, body string) ArtifactRef { + return ArtifactRef{Type: ArtifactRefInline, URI: uri, Body: body} +}