Publish the Promptkit engine facade
This commit is contained in:
20
README.md
20
README.md
@@ -1,23 +1,21 @@
|
|||||||
# Promptkit
|
# Promptkit
|
||||||
|
|
||||||
Promptkit is the reusable Go prompt-execution framework being separated from
|
Promptkit is a reusable Go library for preparing and executing prompt-defined
|
||||||
Scriptorium. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
gitea.maximumdirect.net/eric/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
Framework extraction is in progress. The repository now contains the
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
`internal/domain` model, application-neutral `internal/defaults`, and
|
can configure filesystem or in-memory prompt, profile, and schema sources,
|
||||||
`internal/filecatalog` helpers that form the implementation foundation. It also
|
prepare requests without generation, run requests with the built-in
|
||||||
contains internal prompt-definition and profile repositories, the embedded
|
OpenAI-compatible client, or inject their own model client and artifact reader.
|
||||||
built-in profile catalog, artifact loading, Go-template rendering, output
|
See the [Go package consumer guide](docs/consumers/pkg-promptkit.md) for the
|
||||||
validation, model generation, and orchestration. These internal packages are
|
public workflow and contract.
|
||||||
not a consumer API, and the root package does not yet provide a usable public
|
|
||||||
framework API, so there is no installation or usage example at this time.
|
|
||||||
|
|
||||||
Contributors should start with the [development guide](docs/development.md).
|
Contributors should start with the [development guide](docs/development.md).
|
||||||
The [architecture policy](docs/policy/architecture.md) defines the library
|
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||||
boundary and constraints that future framework work must preserve.
|
boundary and constraints that framework work must preserve.
|
||||||
|
|
||||||
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
||||||
|
|||||||
89
architecture_test.go
Normal file
89
architecture_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const formerModulePath = "gitea.maximumdirect.net/eric/" + "scrip" + "torium"
|
||||||
|
|
||||||
|
func TestRepositoryDoesNotImportFormerModule(t *testing.T) {
|
||||||
|
violations, err := findFormerModuleImports(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect repository imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
t.Fatalf("repository imports the former module:\n%s", strings.Join(violations, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormerModuleGuardFindsNestedImport(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
nested := filepath.Join(root, "nested", "package")
|
||||||
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||||
|
t.Fatalf("create nested package: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(nested, "violation.go")
|
||||||
|
source := "package nested\n\nimport _ " + strconv.Quote(formerModulePath+"/internal/domain") + "\n"
|
||||||
|
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
|
||||||
|
t.Fatalf("write nested source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
violations, err := findFormerModuleImports(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect nested imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("violations = %v, want one nested import", violations)
|
||||||
|
}
|
||||||
|
if !strings.Contains(violations[0], "violation.go") ||
|
||||||
|
!strings.Contains(violations[0], formerModulePath+"/internal/domain") {
|
||||||
|
t.Fatalf("violation = %q, want file and import path", violations[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFormerModuleImports(root string) ([]string, error) {
|
||||||
|
var violations []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
switch entry.Name() {
|
||||||
|
case ".git", "generated", "vendor":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".go" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly|parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ast.IsGenerated(file) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, spec := range file.Imports {
|
||||||
|
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if importPath == formerModulePath || strings.HasPrefix(importPath, formerModulePath+"/") {
|
||||||
|
violations = append(violations, path+": "+importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return violations, err
|
||||||
|
}
|
||||||
39
artifact_reader.go
Normal file
39
artifact_reader.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|
||||||
|
type publicArtifactReaderAdapter struct {
|
||||||
|
reader ArtifactReader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ artifactadapter.Reader = publicArtifactReaderAdapter{}
|
||||||
|
|
||||||
|
func (a publicArtifactReaderAdapter) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
artifact, err := a.reader.Read(ctx, ArtifactRef{
|
||||||
|
Type: ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if artifact == nil {
|
||||||
|
return nil, errNilArtifactReaderResponse
|
||||||
|
}
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
36
artifact_reader_internal_test.go
Normal file
36
artifact_reader_internal_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
reader := internalArtifactReaderFake{
|
||||||
|
artifact: &Artifact{Body: []byte("original")},
|
||||||
|
}
|
||||||
|
adapter := publicArtifactReaderAdapter{reader: &reader}
|
||||||
|
|
||||||
|
artifact, err := adapter.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
URI: "memory://input",
|
||||||
|
Body: "input",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact: %v", err)
|
||||||
|
}
|
||||||
|
artifact.Body[0] = 'X'
|
||||||
|
if got := string(reader.artifact.Body); got != "original" {
|
||||||
|
t.Fatalf("reader artifact body was mutated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalArtifactReaderFake struct {
|
||||||
|
artifact *Artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *internalArtifactReaderFake) Read(context.Context, ArtifactRef) (*Artifact, error) {
|
||||||
|
return r.artifact, nil
|
||||||
|
}
|
||||||
406
convert.go
Normal file
406
convert.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
|
execution, err := toDomainExecutionTargetOverride(req.Execution)
|
||||||
|
if err != nil {
|
||||||
|
return domain.RunRequest{}, err
|
||||||
|
}
|
||||||
|
return domain.RunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
PromptVersion: req.PromptVersion,
|
||||||
|
ProfileID: req.ProfileID,
|
||||||
|
APIKey: req.APIKey,
|
||||||
|
Inputs: toDomainArtifactRefMap(req.Inputs),
|
||||||
|
Vars: copyStringMap(req.Vars),
|
||||||
|
Execution: execution,
|
||||||
|
Validation: toDomainOutputContractPtr(req.Validation),
|
||||||
|
Metadata: copyStringMap(req.Metadata),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fromDomainRunResult(result *domain.RunResult) *RunResult {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RunResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Artifact: fromDomainArtifact(result.Artifact),
|
||||||
|
RawOutput: result.RawOutput,
|
||||||
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
|
PromptID: result.PromptID,
|
||||||
|
PromptVersion: result.PromptVersion,
|
||||||
|
PromptHash: result.PromptHash,
|
||||||
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
|
SelectedProfileID: result.SelectedProfileID,
|
||||||
|
ModelName: result.ModelName,
|
||||||
|
Endpoint: result.Endpoint,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(result.EffectiveModelParams),
|
||||||
|
InputHashes: copyStringMap(result.InputHashes),
|
||||||
|
Usage: fromDomainTokenUsage(result.Usage),
|
||||||
|
StartTime: result.StartTime,
|
||||||
|
EndTime: result.EndTime,
|
||||||
|
Duration: result.Duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainGenerateRequest(req domain.GenerateRequest) GenerateRequest {
|
||||||
|
return GenerateRequest{
|
||||||
|
Prompt: fromDomainRenderedPrompt(req.Prompt),
|
||||||
|
Target: fromDomainExecutionTarget(req.Target),
|
||||||
|
TargetPresence: fromDomainExecutionTargetPresence(req.TargetPresence),
|
||||||
|
StructuredOutput: fromDomainStructuredOutputSpec(req.StructuredOutput),
|
||||||
|
APIKey: req.Target.APIKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: resp.Content,
|
||||||
|
Usage: toDomainTokenUsage(resp.Usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRenderedPrompt(prompt domain.RenderedPrompt) RenderedPrompt {
|
||||||
|
return RenderedPrompt{
|
||||||
|
SessionID: prompt.SessionID,
|
||||||
|
Messages: fromDomainRenderedMessages(prompt.Messages),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, error) {
|
||||||
|
if override == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
extraParams, err := copyPublicJSONMap(override.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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: extraParams,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 toDomainTokenUsage(usage TokenUsage) domain.TokenUsage {
|
||||||
|
return domain.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 {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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 copyReflectValue(reflect.ValueOf(value)).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyReflectValue(value reflect.Value) reflect.Value {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
copied := copyReflectValue(value.Elem())
|
||||||
|
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
out.Set(copied)
|
||||||
|
return out
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type().Elem())
|
||||||
|
out.Elem().Set(copyReflectValue(value.Elem()))
|
||||||
|
return out
|
||||||
|
case reflect.Map:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Array:
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
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
|
||||||
|
}
|
||||||
8
doc.go
8
doc.go
@@ -1,2 +1,8 @@
|
|||||||
// Package promptkit defines the public package boundary for the Promptkit Go module.
|
// Package promptkit provides an embeddable engine for preparing and executing
|
||||||
|
// prompt-defined LLM workflows.
|
||||||
|
//
|
||||||
|
// Applications construct an Engine with NewEngine, select filesystem or
|
||||||
|
// in-memory definition sources with options, and use Prepare or Run to execute
|
||||||
|
// requests. Concrete repositories, validators, and outbound clients remain
|
||||||
|
// internal implementation details.
|
||||||
package promptkit
|
package promptkit
|
||||||
|
|||||||
165
docs/consumers/pkg-promptkit.md
Normal file
165
docs/consumers/pkg-promptkit.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Package `promptkit`
|
||||||
|
|
||||||
|
Import path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
```
|
||||||
|
|
||||||
|
Package `promptkit` is the supported Go contract for in-process prompt
|
||||||
|
preparation and execution. The declarations and their GoDoc in the
|
||||||
|
[root package](../../doc.go) own the exact API; this guide explains how the
|
||||||
|
pieces are used together.
|
||||||
|
|
||||||
|
## Engine Construction And Sources
|
||||||
|
|
||||||
|
Construct an engine with [`NewEngine`, `Config`, and
|
||||||
|
`Option`](../../engine.go). `PromptDir` is required unless a prompt source
|
||||||
|
option is supplied. `ProfileDir` optionally overlays built-in profiles, and an
|
||||||
|
empty `SchemaDir` uses the current directory. `Timeout` is the transport-wide
|
||||||
|
safety cap for the built-in OpenAI-compatible client. An optional `HTTPClient`
|
||||||
|
is cloned; its positive timeout takes precedence.
|
||||||
|
|
||||||
|
Nil options are ignored. Invalid construction, including a nil injected client
|
||||||
|
or artifact reader, returns an error matching `ErrInvalidConfig`.
|
||||||
|
|
||||||
|
The [source options](../../engine.go) replace their matching directory source:
|
||||||
|
|
||||||
|
- `WithPromptFS` and `WithPromptFile` select prompt definitions;
|
||||||
|
- `WithProfileFS` and `WithProfileFile` overlay built-in profiles;
|
||||||
|
- `WithProfiles` adds in-memory profiles ahead of file and built-in profiles;
|
||||||
|
- `WithSchemaFS` and `WithSchemaFile` select JSON Schema documents;
|
||||||
|
- `WithLLMClient` replaces the built-in model client; and
|
||||||
|
- `WithArtifactReader` replaces the default reader for every input.
|
||||||
|
|
||||||
|
Prompt-content and schema paths from an `fs.FS` stay within the configured
|
||||||
|
root. Single-file prompt and profile sources select definitions by YAML ID.
|
||||||
|
Relative prompt content resolves from its prompt file, while a single schema
|
||||||
|
is addressed by its base name.
|
||||||
|
|
||||||
|
Per-generation timeout values from profiles or requests are independent of
|
||||||
|
the transport cap and caller context. An explicit request value of zero
|
||||||
|
disables only the per-generation deadline. The
|
||||||
|
[outbound integration contract](../integrations/openai-compatible-chat.md#timeout-and-cancellation)
|
||||||
|
defines the complete timeout layering.
|
||||||
|
|
||||||
|
## Preparation And Execution
|
||||||
|
|
||||||
|
[`Engine.Prepare` and `Engine.Run`](../../engine.go) accept the public
|
||||||
|
[`RunRequest`](../../types.go). `Prepare` resolves the prompt, profile, input
|
||||||
|
artifacts, validation contract, and rendered messages without calling an LLM.
|
||||||
|
`Run` performs the same preparation, calls the configured client, and validates
|
||||||
|
the generated content.
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "./prompts",
|
||||||
|
ProfileDir: "./profiles",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"transcript": promptkit.File("./transcript.md"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = prepared.Messages
|
||||||
|
```
|
||||||
|
|
||||||
|
[`PreparedRun` and `RunResult`](../../types.go) expose copied public values.
|
||||||
|
Preparation returns effective settings, hashes, rendered messages, selected
|
||||||
|
profile, structured-output information, and timing without resolved secrets or
|
||||||
|
model output. Execution adds the generated artifact and raw output, validation
|
||||||
|
state, model metadata, usage, run ID, and duration.
|
||||||
|
|
||||||
|
A generated-content validation failure returns a result with
|
||||||
|
`Validation.Status == ValidationFailed`. An inability to perform validation
|
||||||
|
returns an error matching `ErrValidation`.
|
||||||
|
|
||||||
|
## Requests, Inputs, And Overrides
|
||||||
|
|
||||||
|
The [request and value declarations](../../types.go) own the available fields,
|
||||||
|
serialized constants, and result shapes. Use `File`, `Inline`, or
|
||||||
|
`InlineWithURI` to construct artifact references. Required declared inputs and
|
||||||
|
every input referenced by a template must be supplied.
|
||||||
|
|
||||||
|
`ExecutionTargetOverride` uses pointers for numeric settings so an explicit
|
||||||
|
zero remains distinct from no override. `ExtraParams` accepts JSON-compatible
|
||||||
|
strings, booleans, finite numbers, string-keyed objects, arrays or slices, and
|
||||||
|
nil. Unsupported values, non-string map keys, non-finite numbers, and cycles
|
||||||
|
match `ErrInvalidConfig` in profiles or `ErrInvalidRequest` in request
|
||||||
|
overrides.
|
||||||
|
|
||||||
|
Returned requests, profiles, prepared values, results, artifacts, maps, and
|
||||||
|
slices are isolated from internal engine state. Consumers and injected
|
||||||
|
extensions should not retain or mutate values owned by another caller.
|
||||||
|
|
||||||
|
## Profiles And Credentials
|
||||||
|
|
||||||
|
[`OpenAICompatibleProfile`](../../profiles.go) constructs an ordinary
|
||||||
|
in-memory profile for an OpenAI-compatible chat-completions endpoint.
|
||||||
|
`WithProfiles` rejects duplicate IDs in one call and gives in-memory profiles
|
||||||
|
precedence over explicit file sources and built-ins.
|
||||||
|
|
||||||
|
Raw API keys do not belong in profiles. File-backed profiles may name an
|
||||||
|
environment variable, while an in-memory profile can require a request key.
|
||||||
|
A direct `RunRequest.APIKey` is request-scoped and takes precedence over an
|
||||||
|
environment lookup for the built-in client.
|
||||||
|
|
||||||
|
API keys are excluded from JSON, prepared values, and results. The public
|
||||||
|
`String` and `GoString` methods report only whether a direct key is present.
|
||||||
|
Avoid reflection-based dumps of request structs, which can bypass that
|
||||||
|
redaction.
|
||||||
|
|
||||||
|
## Extension Interfaces
|
||||||
|
|
||||||
|
The [`LLMClient`, `GenerateRequest`, and
|
||||||
|
`GenerateResponse`](../../types.go) boundary lets a consumer replace model
|
||||||
|
generation. Injected clients receive copied rendered messages, effective
|
||||||
|
settings, explicit numeric-setting presence, structured-output constraints,
|
||||||
|
and the request-scoped key. They return generated content and token usage.
|
||||||
|
|
||||||
|
The [`ArtifactReader`](../../types.go) boundary replaces the default inline and
|
||||||
|
file reader for every input. Readers provide artifact content and metadata; the
|
||||||
|
engine fills an empty artifact name from the input-map key. A reader error
|
||||||
|
matches `ErrArtifactLoad` while preserving the original identity for
|
||||||
|
`errors.Is`. A nil artifact with a nil error is also an artifact-load failure.
|
||||||
|
|
||||||
|
Extensions should honor context cancellation and avoid logging raw prompts,
|
||||||
|
artifacts, or credentials.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
The [public error declarations](../../engine.go) and
|
||||||
|
[mapping](../../errors.go) preserve these sentinel checks through `errors.Is`:
|
||||||
|
|
||||||
|
- `ErrInvalidConfig`
|
||||||
|
- `ErrInvalidRequest`
|
||||||
|
- `ErrPromptNotFound`
|
||||||
|
- `ErrProfileNotFound`
|
||||||
|
- `ErrProfileRequired`
|
||||||
|
- `ErrPromptLoad`
|
||||||
|
- `ErrProfileLoad`
|
||||||
|
- `ErrAPIKeyEnvMissing`
|
||||||
|
- `ErrArtifactLoad`
|
||||||
|
- `ErrPromptRender`
|
||||||
|
- `ErrLLMGenerate`
|
||||||
|
- `ErrValidation`
|
||||||
|
|
||||||
|
`ErrProfileRequired` and `ErrAPIKeyEnvMissing` also match
|
||||||
|
`ErrInvalidRequest`, allowing either broad request handling or a specific
|
||||||
|
condition. Wrapped collaborator errors retain their identity where the public
|
||||||
|
contract promises it.
|
||||||
|
|
||||||
|
## Consumer Boundary
|
||||||
|
|
||||||
|
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||||
|
API, process configuration, or deployment policy. Scriptorium is one
|
||||||
|
downstream application that maps this root package contract into those
|
||||||
|
application concerns.
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
This document defines the outbound HTTP behavior implemented by Promptkit's
|
This document defines the outbound HTTP behavior implemented by Promptkit's
|
||||||
internal OpenAI-compatible model client. The
|
internal OpenAI-compatible model client. The
|
||||||
[internal model-client document](../internal/llm.md) owns implementation flow,
|
[internal model-client document](../internal/llm.md) owns implementation flow,
|
||||||
errors, and test ownership. The client is not yet available through a usable
|
errors, and test ownership. The root Promptkit engine uses this client by
|
||||||
public Promptkit engine.
|
default unless a consumer injects another implementation.
|
||||||
|
|
||||||
## Endpoint And Method
|
## Endpoint And Method
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ and the
|
|||||||
[OpenAI-compatible chat integration](../integrations/openai-compatible-chat.md)
|
[OpenAI-compatible chat integration](../integrations/openai-compatible-chat.md)
|
||||||
owns the observable outbound HTTP contract.
|
owns the observable outbound HTTP contract.
|
||||||
|
|
||||||
The client is implemented only under `internal/llm`. The root package does not
|
The concrete client remains under `internal/llm`. The root engine assembles it
|
||||||
yet assemble it into a usable public engine.
|
as the default implementation behind Promptkit's public client boundary.
|
||||||
|
|
||||||
## Components And Flow
|
## Components And Flow
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ non-success provider statuses, and malformed successful responses. Provider
|
|||||||
response bodies are not included in non-success errors.
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
Caller cancellation and deadline failures during the outbound request are
|
Caller cancellation and deadline failures during the outbound request are
|
||||||
reported as request execution failures. The future runner can classify these
|
reported as request execution failures. The runner classifies these identities
|
||||||
identities without depending on HTTP status mapping.
|
without depending on HTTP status mapping.
|
||||||
|
|
||||||
## Test Ownership
|
## Test Ownership
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Root `promptkit` package | Establishes the public package boundary for the Go module. It does not yet provide migrated framework behavior or exported APIs. | [Package declaration](../../doc.go) |
|
| Root `promptkit` package | Provides the supported engine facade, source and injection options, public request and result values, built-in profile construction, extension interfaces, value conversion, redacted formatting, and public error mapping. | [Package GoDoc](../../doc.go), [engine assembly](../../engine.go) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
@@ -24,9 +24,8 @@ contributor workflow and validation.
|
|||||||
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests, response decoding, authentication, and deadline handling. | [Internal model client](llm.md) |
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests, response decoding, authentication, and deadline handling. | [Internal model client](llm.md) |
|
||||||
| `internal/usecase` | Coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
| `internal/usecase` | Coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
||||||
|
|
||||||
These packages provide the internal model, source, rendering, validation, and
|
The root package assembles these internal components without exposing their
|
||||||
model-client workflow. A usable public engine is not implemented in Promptkit
|
representations. Consumers depend only on the root facade.
|
||||||
yet.
|
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ artifact, rendering, and validation behavior, while the
|
|||||||
[model-client document](llm.md) owns generation behavior and failure
|
[model-client document](llm.md) owns generation behavior and failure
|
||||||
categories.
|
categories.
|
||||||
|
|
||||||
The runner remains under `internal/usecase`. The root package does not yet
|
The runner remains under `internal/usecase` and is assembled by the root
|
||||||
assemble it into a usable public engine.
|
Promptkit engine. Its concrete type is not part of the public API.
|
||||||
|
|
||||||
## Collaborators
|
## Collaborators
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ input hashes, token usage, a generated run identifier, and UTC timing.
|
|||||||
|
|
||||||
Package errors distinguish invalid requests, required profile selection,
|
Package errors distinguish invalid requests, required profile selection,
|
||||||
credential failures, and prompt, profile, artifact, rendering, generation, and
|
credential failures, and prompt, profile, artifact, rendering, generation, and
|
||||||
validation failures. Wrapping preserves the package identities needed by the
|
validation failures. Wrapping preserves the package identities mapped by the
|
||||||
future facade and retains collaborator identities where they are part of the
|
public facade and retains collaborator identities where they are part of the
|
||||||
internal contract. Context cancellation propagates through the invoked
|
internal contract. Context cancellation propagates through the invoked
|
||||||
collaborator and is classified by the owning operation.
|
collaborator and is classified by the owning operation.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes Promptkit's implemented internal source, artifact,
|
|||||||
rendering, and output-validation behavior. The
|
rendering, and output-validation behavior. The
|
||||||
[architecture policy](../policy/architecture.md) owns the library boundary and
|
[architecture policy](../policy/architecture.md) owns the library boundary and
|
||||||
dependency rules. None of these internal packages is a supported consumer API,
|
dependency rules. None of these internal packages is a supported consumer API,
|
||||||
and the root package does not yet assemble them into a usable engine.
|
and the root engine assembles them behind its public source options and values.
|
||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ Promptkit is an importable Go library. It does not provide a runnable command,
|
|||||||
an HTTP service, or another application process.
|
an HTTP service, or another application process.
|
||||||
|
|
||||||
The module root contains package `promptkit`, which is the public facade. It
|
The module root contains package `promptkit`, which is the public facade. It
|
||||||
declares the module's public package boundary but does not yet provide a usable
|
provides the supported engine, configuration and source options, requests,
|
||||||
exported framework API.
|
results, public values, extension interfaces, profiles, and error sentinels.
|
||||||
|
|
||||||
The implemented internal components consist of:
|
The implemented internal components consist of:
|
||||||
|
|
||||||
@@ -40,17 +40,19 @@ The implemented internal components consist of:
|
|||||||
- `internal/usecase`, which coordinates preparation and execution across the
|
- `internal/usecase`, which coordinates preparation and execution across the
|
||||||
internal framework components.
|
internal framework components.
|
||||||
|
|
||||||
The defaults and renderer depend on the domain model. Prompt-definition and
|
The root facade assembles the internal repositories, renderer, validator,
|
||||||
profile repositories use the domain model, file catalog, and YAML decoder. The
|
outbound client, and use-case runner while translating public values and
|
||||||
built-in profile repository supplies an embedded `fs.FS` to the profile
|
errors at the library boundary. The defaults and renderer depend on the domain
|
||||||
package. Artifact reading uses the domain model and application-neutral
|
model. Prompt-definition and profile repositories use the domain model, file
|
||||||
defaults. Validation uses the domain model, file catalog, and JSON Schema
|
catalog, and YAML decoder. The built-in profile repository supplies an
|
||||||
implementation. The model client uses the domain model, application-neutral
|
embedded `fs.FS` to the profile package. Artifact reading uses the domain model
|
||||||
defaults, and an injected or standard-library HTTP client. The use-case runner
|
and application-neutral defaults. Validation uses the domain model, file
|
||||||
depends on the narrow interfaces owned by each internal component. The public
|
catalog, and JSON Schema implementation. The model client uses the domain
|
||||||
engine has not yet been extracted.
|
model, application-neutral defaults, and an injected or standard-library HTTP
|
||||||
|
client. The use-case runner depends on the narrow interfaces owned by each
|
||||||
|
internal component.
|
||||||
|
|
||||||
Future framework extraction must follow this dependency direction:
|
The current implementation follows this dependency direction:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
downstream consumers, including Scriptorium
|
downstream consumers, including Scriptorium
|
||||||
@@ -65,16 +67,13 @@ downstream consumers, including Scriptorium
|
|||||||
narrow injected abstractions
|
narrow injected abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
The facade may coordinate internal components once the public engine is
|
The facade coordinates internal components and adapts the supported public
|
||||||
extracted. Internal components must depend on narrow abstractions for behavior
|
extension interfaces to narrow internal abstractions. Internal components must
|
||||||
supplied from outside the library; they must not depend on consumers or on
|
not depend on consumers or on Scriptorium.
|
||||||
Scriptorium. This diagram is the target dependency direction for later
|
|
||||||
extraction and does not assert that the public facade already assembles the
|
|
||||||
implemented foundation.
|
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
Scriptorium is a downstream application that will consume Promptkit through
|
Scriptorium is a downstream application that consumes Promptkit through
|
||||||
the supported public facade. It is not a Promptkit package and must not become
|
the supported public facade. It is not a Promptkit package and must not become
|
||||||
an internal dependency.
|
an internal dependency.
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ state.
|
|||||||
|
|
||||||
## Current-State Maintenance
|
## Current-State Maintenance
|
||||||
|
|
||||||
This policy distinguishes present implementation from constraints on future
|
Do not list planned packages as implemented components. When implementation
|
||||||
framework extraction. Do not list planned packages as implemented components.
|
introduces a package, update the internal inventory and the owning contract or
|
||||||
When extraction introduces a package, update the internal inventory and the
|
subsystem document in the same change.
|
||||||
owning contract or subsystem document in the same change.
|
|
||||||
|
|||||||
342
engine.go
Normal file
342
engine.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile/builtin"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidConfig indicates invalid public engine configuration.
|
||||||
|
var ErrInvalidConfig = errors.New("invalid engine configuration")
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("invalid run request")
|
||||||
|
ErrPromptNotFound = errors.New("prompt not found")
|
||||||
|
ErrProfileNotFound = errors.New("profile not found")
|
||||||
|
ErrProfileRequired = errors.New("profile selection is required")
|
||||||
|
ErrPromptLoad = errors.New("failed to load prompt definition")
|
||||||
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
||||||
|
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine prepares and runs Promptkit prompt requests.
|
||||||
|
type Engine struct {
|
||||||
|
runner *usecase.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config configures a public Promptkit engine.
|
||||||
|
type Config struct {
|
||||||
|
PromptDir string
|
||||||
|
ProfileDir string
|
||||||
|
SchemaDir string
|
||||||
|
// Timeout is the transport-wide safety cap for the built-in LLM client
|
||||||
|
// when HTTPClient is absent or has a non-positive timeout.
|
||||||
|
Timeout time.Duration
|
||||||
|
// HTTPClient is cloned for the built-in LLM client. Its positive Timeout
|
||||||
|
// takes precedence over Config.Timeout as the transport-wide safety cap.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option customizes engine construction.
|
||||||
|
type Option interface {
|
||||||
|
apply(*engineOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionFunc func(*engineOptions) error
|
||||||
|
|
||||||
|
func (f optionFunc) apply(options *engineOptions) error {
|
||||||
|
return f(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
type engineOptions struct {
|
||||||
|
llmClient llm.Client
|
||||||
|
artifactReader artifactadapter.Reader
|
||||||
|
promptDefs promptdef.Repository
|
||||||
|
profiles profile.Repository
|
||||||
|
memoryProfiles profile.Repository
|
||||||
|
validator validate.Validator
|
||||||
|
promptSource bool
|
||||||
|
profileSource bool
|
||||||
|
memorySource bool
|
||||||
|
validatorSource bool
|
||||||
|
artifactSource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLLMClient injects a custom LLM client for execution.
|
||||||
|
func WithLLMClient(client LLMClient) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if client == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.llmClient = publicLLMClientAdapter{client: client}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithArtifactReader injects a reader for every input artifact reference.
|
||||||
|
func WithArtifactReader(reader ArtifactReader) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if reader == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
||||||
|
options.artifactSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFS loads prompt definitions from fsys under root.
|
||||||
|
//
|
||||||
|
// The source uses the same strict prompt YAML rules as configured prompt
|
||||||
|
// directories, and prompt content_file paths resolve within this source.
|
||||||
|
func WithPromptFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFile loads prompt definitions from the single prompt file at path.
|
||||||
|
//
|
||||||
|
// Relative prompt content_file paths resolve from the file's directory.
|
||||||
|
func WithPromptFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFS loads execution profiles from fsys under root.
|
||||||
|
//
|
||||||
|
// Profiles from this source overlay built-in profiles. Profile YAML must use
|
||||||
|
// api_key_env for environment-based credentials; raw API keys are rejected.
|
||||||
|
func WithProfileFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFile loads execution profiles from the single profile file at path.
|
||||||
|
//
|
||||||
|
// The profile overlays built-in profiles. Profile YAML must use api_key_env for
|
||||||
|
// environment-based credentials; raw API keys are rejected.
|
||||||
|
func WithProfileFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfiles configures in-memory profiles that take precedence over
|
||||||
|
// configured profile files and built-in profiles.
|
||||||
|
func WithProfiles(profiles ...Profile) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
repo, err := newMemoryProfileRepository(profiles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.memoryProfiles = repo
|
||||||
|
options.memorySource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFS loads JSON Schema documents from fsys under root.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values resolve within this source when schema validation
|
||||||
|
// or structured output is requested.
|
||||||
|
func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFile loads JSON Schema documents from the single schema file at path.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values refer to the file's base name.
|
||||||
|
func WithSchemaFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||||
|
var options engineOptions
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := opt.apply(&options); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptDefs := options.promptDefs
|
||||||
|
if !options.promptSource {
|
||||||
|
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
|
||||||
|
if options.profileSource {
|
||||||
|
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
|
||||||
|
}
|
||||||
|
if options.memorySource {
|
||||||
|
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := options.validator
|
||||||
|
if !options.validatorSource {
|
||||||
|
schemaDir := cfg.SchemaDir
|
||||||
|
if strings.TrimSpace(schemaDir) == "" {
|
||||||
|
schemaDir = defaults.SchemaDirDefault
|
||||||
|
}
|
||||||
|
validator = validate.NewStandardValidator(schemaDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
llmClient := options.llmClient
|
||||||
|
if llmClient == nil {
|
||||||
|
var err error
|
||||||
|
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
HTTPClient: cfg.HTTPClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := options.artifactReader
|
||||||
|
if !options.artifactSource {
|
||||||
|
artifacts = artifactadapter.NewCompositeReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Engine{
|
||||||
|
runner: usecase.NewRunner(
|
||||||
|
promptDefs,
|
||||||
|
profiles,
|
||||||
|
artifacts,
|
||||||
|
prompt.NewGoRenderer(),
|
||||||
|
llmClient,
|
||||||
|
validator,
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSource(name string) (fs.FS, string, error) {
|
||||||
|
cleanName := strings.TrimSpace(name)
|
||||||
|
if cleanName == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(cleanName)
|
||||||
|
base := filepath.Base(cleanName)
|
||||||
|
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
info, err := os.Stat(cleanName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
|
||||||
|
}
|
||||||
|
return os.DirFS(dir), filepath.ToSlash(base), 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := e.runner.Prepare(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainPreparedRun(prepared), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a prompt request and returns the generated artifact and metadata.
|
||||||
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.runner.Run(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainRunResult(result), nil
|
||||||
|
}
|
||||||
2559
engine_test.go
Normal file
2559
engine_test.go
Normal file
File diff suppressed because it is too large
Load Diff
60
errors.go
Normal file
60
errors.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapPublicError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
publicErr := publicErrorFor(err)
|
||||||
|
if publicErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicErrorFor(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
||||||
|
return ErrPromptNotFound
|
||||||
|
case errors.Is(err, profile.ErrProfileNotFound):
|
||||||
|
return ErrProfileNotFound
|
||||||
|
case errors.Is(err, usecase.ErrProfileRequired):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrProfileRequired)
|
||||||
|
case errors.Is(err, usecase.ErrPromptLoad):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case errors.Is(err, usecase.ErrProfileLoad):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case isProfileLoadCause(err):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
||||||
|
case errors.Is(err, usecase.ErrArtifactLoad):
|
||||||
|
return ErrArtifactLoad
|
||||||
|
case errors.Is(err, usecase.ErrPromptRender):
|
||||||
|
return ErrPromptRender
|
||||||
|
case errors.Is(err, usecase.ErrLLMGenerate):
|
||||||
|
return ErrLLMGenerate
|
||||||
|
case errors.Is(err, usecase.ErrValidation):
|
||||||
|
return ErrValidation
|
||||||
|
case errors.Is(err, usecase.ErrInvalidRequest):
|
||||||
|
return ErrInvalidRequest
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProfileLoadCause(err error) bool {
|
||||||
|
return errors.Is(err, profile.ErrInvalidYAML) ||
|
||||||
|
errors.Is(err, profile.ErrInvalidProfile) ||
|
||||||
|
errors.Is(err, profile.ErrRawAPIKeyNotAllowed)
|
||||||
|
}
|
||||||
51
formatting.go
Normal file
51
formatting.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing direct API keys.
|
||||||
|
func (r RunRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing direct API keys.
|
||||||
|
func (r RunRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RunRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t Metadata:%d}",
|
||||||
|
r.PromptID,
|
||||||
|
r.PromptVersion,
|
||||||
|
r.ProfileID,
|
||||||
|
r.APIKey != "",
|
||||||
|
len(r.Inputs),
|
||||||
|
len(r.Vars),
|
||||||
|
r.Execution != nil,
|
||||||
|
r.Validation != nil,
|
||||||
|
len(r.Metadata),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content.
|
||||||
|
func (r GenerateRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content.
|
||||||
|
func (r GenerateRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r GenerateRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.GenerateRequest{Messages:%d Model:%q APIKeySet:%t StructuredOutputSet:%t ExtraParams:%d}",
|
||||||
|
len(r.Prompt.Messages),
|
||||||
|
r.Target.Model,
|
||||||
|
r.APIKey != "",
|
||||||
|
r.StructuredOutput != nil,
|
||||||
|
len(r.Target.ExtraParams),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
||||||
const envName = "SCRIPTORIUM_TEST_API_KEY"
|
const envName = "PROMPTKIT_TEST_API_KEY"
|
||||||
const secret = "super-secret-value"
|
const secret = "super-secret-value"
|
||||||
t.Setenv(envName, secret)
|
t.Setenv(envName, secret)
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected constructor error: %v", err)
|
t.Fatalf("unexpected constructor error: %v", err)
|
||||||
}
|
}
|
||||||
t.Setenv("SCRIPTORIUM_TEST_API_KEY", "secret-key")
|
t.Setenv("PROMPTKIT_TEST_API_KEY", "secret-key")
|
||||||
|
|
||||||
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
||||||
@@ -189,7 +189,7 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
|||||||
MaxTokens: 123,
|
MaxTokens: 123,
|
||||||
TopP: 0.7,
|
TopP: 0.7,
|
||||||
ServiceTier: "priority",
|
ServiceTier: "priority",
|
||||||
APIKeyEnv: "SCRIPTORIUM_TEST_API_KEY",
|
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
||||||
},
|
},
|
||||||
StructuredOutput: &domain.StructuredOutputSpec{
|
StructuredOutput: &domain.StructuredOutputSpec{
|
||||||
Type: domain.StructuredOutputJSONSchema,
|
Type: domain.StructuredOutputJSONSchema,
|
||||||
@@ -276,7 +276,7 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestOpenAICompatibleClientDirectAPIKeyPreferredOverEnv(t *testing.T) {
|
func TestOpenAICompatibleClientDirectAPIKeyPreferredOverEnv(t *testing.T) {
|
||||||
const directKey = "direct-llm-key"
|
const directKey = "direct-llm-key"
|
||||||
t.Setenv("SCRIPTORIUM_TEST_API_KEY", "env-key")
|
t.Setenv("PROMPTKIT_TEST_API_KEY", "env-key")
|
||||||
|
|
||||||
var gotAuth string
|
var gotAuth string
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -294,7 +294,7 @@ func TestOpenAICompatibleClientDirectAPIKeyPreferredOverEnv(t *testing.T) {
|
|||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
Target: domain.ExecutionTarget{
|
Target: domain.ExecutionTarget{
|
||||||
Model: "model",
|
Model: "model",
|
||||||
APIKeyEnv: "SCRIPTORIUM_TEST_API_KEY",
|
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
||||||
APIKey: directKey,
|
APIKey: directKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -870,7 +870,7 @@ func TestOpenAICompatibleClientAPIKeyEnvMissing(t *testing.T) {
|
|||||||
|
|
||||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
Target: domain.ExecutionTarget{APIKeyEnv: "SCRIPTORIUM_MISSING_KEY"},
|
Target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected missing API key env error")
|
t.Fatal("expected missing API key env error")
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
if p.APIKeyEnv != "SCRIPTORIUM_API_KEY" {
|
if p.APIKeyEnv != "PROMPTKIT_API_KEY" {
|
||||||
t.Fatalf("unexpected api_key_env: %q", p.APIKeyEnv)
|
t.Fatalf("unexpected api_key_env: %q", p.APIKeyEnv)
|
||||||
}
|
}
|
||||||
if p.ReasoningEffort != "medium" {
|
if p.ReasoningEffort != "medium" {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id: local-secure
|
id: local-secure
|
||||||
endpoint: http://localhost:8000/v1
|
endpoint: http://localhost:8000/v1
|
||||||
model: gpt-4o-mini
|
model: gpt-4o-mini
|
||||||
api_key_env: SCRIPTORIUM_API_KEY
|
api_key_env: PROMPTKIT_API_KEY
|
||||||
service_tier: priority
|
service_tier: priority
|
||||||
reasoning_effort: medium
|
reasoning_effort: medium
|
||||||
extra_params:
|
extra_params:
|
||||||
|
|||||||
218
json_copy.go
Normal file
218
json_copy.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxSafeJSONInteger = 1<<53 - 1
|
||||||
|
|
||||||
|
type jsonVisit struct {
|
||||||
|
typ reflect.Type
|
||||||
|
ptr uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPublicJSONMap(src map[string]any) (map[string]any, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
copied, err := copyPublicJSONValue(reflect.ValueOf(src), "extra_params", make(map[jsonVisit]struct{}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, ok := copied.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("extra_params: expected object")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPublicJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if value.Kind() == reflect.Interface {
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return copyPublicJSONValue(value.Elem(), path, seen)
|
||||||
|
}
|
||||||
|
if !value.CanInterface() {
|
||||||
|
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
||||||
|
}
|
||||||
|
if number, ok := value.Interface().(json.Number); ok {
|
||||||
|
f, err := strconv.ParseFloat(number.String(), 64)
|
||||||
|
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Bool, reflect.String:
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger {
|
||||||
|
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
if value.Uint() > maxSafeJSONInteger {
|
||||||
|
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
f := value.Convert(reflect.TypeOf(float64(0))).Float()
|
||||||
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
defer delete(seen, visit)
|
||||||
|
return copyPublicJSONValue(value.Elem(), path, seen)
|
||||||
|
case reflect.Map:
|
||||||
|
return copyPublicJSONMapValue(value, path, seen)
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return copyPublicJSONSequenceValue(value, path, seen)
|
||||||
|
case reflect.Array:
|
||||||
|
return copyPublicJSONSequenceValue(value, path, seen)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPublicJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
||||||
|
}
|
||||||
|
|
||||||
|
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
defer delete(seen, visit)
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
key reflect.Value
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}
|
||||||
|
entries := make([]entry, 0, value.Len())
|
||||||
|
preserveType := true
|
||||||
|
elemType := value.Type().Elem()
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
key := iter.Key()
|
||||||
|
name := key.String()
|
||||||
|
copied, err := copyPublicJSONValue(iter.Value(), path+"."+name, seen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, entry{key: key, name: name, value: copied})
|
||||||
|
if copied == nil {
|
||||||
|
if !canAssignNil(elemType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.TypeOf(copied).AssignableTo(elemType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preserveType {
|
||||||
|
out := reflect.MakeMapWithSize(value.Type(), len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.value == nil {
|
||||||
|
out.SetMapIndex(entry.key, reflect.Zero(elemType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.SetMapIndex(entry.key, reflect.ValueOf(entry.value))
|
||||||
|
}
|
||||||
|
return out.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
out[entry.name] = entry.value
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPublicJSONSequenceValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
||||||
|
var visit jsonVisit
|
||||||
|
if value.Kind() == reflect.Slice {
|
||||||
|
visit = jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
defer delete(seen, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]any, value.Len())
|
||||||
|
preserveType := true
|
||||||
|
elemType := value.Type().Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
copied, err := copyPublicJSONValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values[i] = copied
|
||||||
|
if copied == nil {
|
||||||
|
if !canAssignNil(elemType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.TypeOf(copied).AssignableTo(elemType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preserveType {
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
if value.Kind() == reflect.Slice {
|
||||||
|
out = reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
||||||
|
}
|
||||||
|
for i, copied := range values {
|
||||||
|
if copied == nil {
|
||||||
|
out.Index(i).Set(reflect.Zero(elemType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Index(i).Set(reflect.ValueOf(copied))
|
||||||
|
}
|
||||||
|
return out.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]any, len(values))
|
||||||
|
copy(out, values)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAssignNil(typ reflect.Type) bool {
|
||||||
|
switch typ.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
23
llm_adapter.go
Normal file
23
llm_adapter.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publicLLMClientAdapter struct {
|
||||||
|
client LLMClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a publicLLMClientAdapter) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
resp, err := a.client.Generate(ctx, fromDomainGenerateRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, fmt.Errorf("%w: llm client returned nil response", ErrLLMGenerate)
|
||||||
|
}
|
||||||
|
return toDomainGenerateResponse(resp), nil
|
||||||
|
}
|
||||||
124
profiles.go
Normal file
124
profiles.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAICompatibleProfile returns an ordinary in-memory Profile for an
|
||||||
|
// OpenAI-compatible chat-completions endpoint.
|
||||||
|
//
|
||||||
|
// It does not register global state, maintain a model catalog, or resolve
|
||||||
|
// credentials. If APIKeyRequired is true, callers satisfy it with
|
||||||
|
// RunRequest.APIKey. Raw API keys do not belong in profiles.
|
||||||
|
func OpenAICompatibleProfile(cfg OpenAICompatibleProfileConfig) Profile {
|
||||||
|
return Profile{
|
||||||
|
ID: cfg.ID,
|
||||||
|
Endpoint: cfg.Endpoint,
|
||||||
|
Model: cfg.Model,
|
||||||
|
Temperature: cfg.Temperature,
|
||||||
|
MaxTokens: cfg.MaxTokens,
|
||||||
|
TopP: cfg.TopP,
|
||||||
|
TimeoutSeconds: cfg.TimeoutSeconds,
|
||||||
|
ServiceTier: cfg.ServiceTier,
|
||||||
|
ReasoningEffort: cfg.ReasoningEffort,
|
||||||
|
APIKeyRequired: cfg.APIKeyRequired,
|
||||||
|
ExtraParams: copyShallowAnyMap(cfg.ExtraParams),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyShallowAnyMap(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] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryProfileRepository struct {
|
||||||
|
profiles map[string]domain.ExecutionProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryProfileRepository(profiles []Profile) (*memoryProfileRepository, error) {
|
||||||
|
repo := &memoryProfileRepository{profiles: make(map[string]domain.ExecutionProfile, len(profiles))}
|
||||||
|
for _, publicProfile := range profiles {
|
||||||
|
prof, err := toDomainProfile(publicProfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, exists := repo.profiles[prof.ID]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate profile id %q", prof.ID)
|
||||||
|
}
|
||||||
|
repo.profiles[prof.ID] = prof
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memoryProfileRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, profile.ErrProfileNotFound
|
||||||
|
}
|
||||||
|
prof, ok := r.profiles[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, profile.ErrProfileNotFound
|
||||||
|
}
|
||||||
|
prof.ExtraParams = copyAnyMap(prof.ExtraParams)
|
||||||
|
return &prof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
||||||
|
extraParams, err := copyPublicJSONMap(publicProfile.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionProfile{}, err
|
||||||
|
}
|
||||||
|
prof := domain.ExecutionProfile{
|
||||||
|
ID: strings.TrimSpace(publicProfile.ID),
|
||||||
|
Endpoint: publicProfile.Endpoint,
|
||||||
|
Model: publicProfile.Model,
|
||||||
|
Temperature: publicProfile.Temperature,
|
||||||
|
MaxTokens: publicProfile.MaxTokens,
|
||||||
|
TopP: publicProfile.TopP,
|
||||||
|
TimeoutSeconds: publicProfile.TimeoutSeconds,
|
||||||
|
ServiceTier: publicProfile.ServiceTier,
|
||||||
|
ReasoningEffort: publicProfile.ReasoningEffort,
|
||||||
|
APIKeyRequired: publicProfile.APIKeyRequired,
|
||||||
|
ExtraParams: extraParams,
|
||||||
|
}
|
||||||
|
if err := validatePublicProfile(prof); err != nil {
|
||||||
|
return domain.ExecutionProfile{}, err
|
||||||
|
}
|
||||||
|
return prof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePublicProfile(prof domain.ExecutionProfile) error {
|
||||||
|
if strings.TrimSpace(prof.ID) == "" {
|
||||||
|
return errors.New("id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(prof.Endpoint) == "" {
|
||||||
|
return errors.New("endpoint is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(prof.Model) == "" {
|
||||||
|
return errors.New("model is required")
|
||||||
|
}
|
||||||
|
if prof.Temperature < 0 || prof.Temperature > 2 {
|
||||||
|
return errors.New("temperature must be between 0 and 2")
|
||||||
|
}
|
||||||
|
if prof.MaxTokens < 0 {
|
||||||
|
return errors.New("max_tokens must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if prof.TopP < 0 || prof.TopP > 1 {
|
||||||
|
return errors.New("top_p must be between 0 and 1")
|
||||||
|
}
|
||||||
|
if prof.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout_seconds must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
2
testdata/framework/fixtures/glossary.yml
vendored
Normal file
2
testdata/framework/fixtures/glossary.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
archive: A catalogued collection of written records.
|
||||||
|
marker: A small label used to classify an entry.
|
||||||
2
testdata/framework/fixtures/transcript.md
vendored
Normal file
2
testdata/framework/fixtures/transcript.md
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Nia labels the archive.
|
||||||
|
The archive receives a blue marker.
|
||||||
7
testdata/framework/profiles/contract-fast.yaml
vendored
Normal file
7
testdata/framework/profiles/contract-fast.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: contract-fast
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: contract-fast-model
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 500
|
||||||
|
top_p: 1
|
||||||
|
timeout_seconds: 90
|
||||||
7
testdata/framework/profiles/contract-quality.yaml
vendored
Normal file
7
testdata/framework/profiles/contract-quality.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: contract-quality
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: contract-quality-model
|
||||||
|
temperature: 0.1
|
||||||
|
max_tokens: 1000
|
||||||
|
top_p: 0.9
|
||||||
|
timeout_seconds: 120
|
||||||
1
testdata/framework/prompts/contract.markdown_summary.system.md
vendored
Normal file
1
testdata/framework/prompts/contract.markdown_summary.system.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
You summarize synthetic archive notes in clear Markdown.
|
||||||
7
testdata/framework/prompts/contract.markdown_summary.user.md
vendored
Normal file
7
testdata/framework/prompts/contract.markdown_summary.user.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Summarize this transcript:
|
||||||
|
|
||||||
|
{{input "transcript"}}
|
||||||
|
|
||||||
|
Optional glossary:
|
||||||
|
|
||||||
|
{{input "glossary"}}
|
||||||
20
testdata/framework/prompts/contract.markdown_summary.yaml
vendored
Normal file
20
testdata/framework/prompts/contract.markdown_summary.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
id: contract.markdown_summary
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: contract-fast
|
||||||
|
description: Summarize a synthetic transcript in Markdown.
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
content_type: text/markdown
|
||||||
|
- name: glossary
|
||||||
|
required: false
|
||||||
|
content_type: text/yaml
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ./contract.markdown_summary.system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ./contract.markdown_summary.user.md
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
1
testdata/framework/prompts/contract.structured_events.system.md
vendored
Normal file
1
testdata/framework/prompts/contract.structured_events.system.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Return only JSON that satisfies the requested event schema.
|
||||||
7
testdata/framework/prompts/contract.structured_events.user.md
vendored
Normal file
7
testdata/framework/prompts/contract.structured_events.user.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Extract events from this transcript:
|
||||||
|
|
||||||
|
{{input "transcript"}}
|
||||||
|
|
||||||
|
Optional glossary:
|
||||||
|
|
||||||
|
{{input "glossary"}}
|
||||||
21
testdata/framework/prompts/contract.structured_events.yaml
vendored
Normal file
21
testdata/framework/prompts/contract.structured_events.yaml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
id: contract.structured_events
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: contract-quality
|
||||||
|
description: Extract synthetic events as structured JSON.
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
content_type: text/markdown
|
||||||
|
- name: glossary
|
||||||
|
required: false
|
||||||
|
content_type: text/yaml
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ./contract.structured_events.system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ./contract.structured_events.user.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: structured_events.schema.json
|
||||||
|
repair_attempts: 0
|
||||||
19
testdata/framework/schemas/structured_events.schema.json
vendored
Normal file
19
testdata/framework/schemas/structured_events.schema.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["events"],
|
||||||
|
"properties": {
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
306
types.go
Normal file
306
types.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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 or run a single prompt.
|
||||||
|
type RunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
APIKey string `json:"-"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult contains generated output, validation state, and run metadata.
|
||||||
|
type RunResult struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Artifact Artifact `json:"artifact"`
|
||||||
|
RawOutput string `json:"raw_output"`
|
||||||
|
Validation ValidationResult `json:"validation"`
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
PromptVersion string `json:"prompt_version,omitempty"`
|
||||||
|
PromptHash string `json:"prompt_hash,omitempty"`
|
||||||
|
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
||||||
|
SelectedProfileID string `json:"selected_profile_id"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
||||||
|
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
||||||
|
Usage TokenUsage `json:"usage"`
|
||||||
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime time.Time `json:"end_time,omitempty"`
|
||||||
|
Duration time.Duration `json:"duration,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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactReader resolves a prompt input reference into its content.
|
||||||
|
//
|
||||||
|
// Readers are responsible for supplying artifact metadata. The engine assigns
|
||||||
|
// an input-map name only when the returned artifact name is empty.
|
||||||
|
type ArtifactReader interface {
|
||||||
|
Read(context.Context, ArtifactRef) (*Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is an in-memory execution profile for library consumers.
|
||||||
|
//
|
||||||
|
// It is equivalent to a loaded profile file after validation. Raw API keys do
|
||||||
|
// not belong in profiles; use APIKeyRequired to require callers to provide
|
||||||
|
// RunRequest.APIKey for each request, or use profile YAML api_key_env with file
|
||||||
|
// and FS profile sources.
|
||||||
|
type Profile struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
Model string
|
||||||
|
Temperature float64
|
||||||
|
MaxTokens int
|
||||||
|
TopP float64
|
||||||
|
TimeoutSeconds int
|
||||||
|
ServiceTier string
|
||||||
|
ReasoningEffort string
|
||||||
|
APIKeyRequired bool
|
||||||
|
ExtraParams map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAICompatibleProfileConfig configures an OpenAI-compatible in-memory
|
||||||
|
// profile.
|
||||||
|
//
|
||||||
|
// It contains ordinary profile fields for OpenAI-compatible chat-completions
|
||||||
|
// endpoints. APIKeyRequired is satisfied by RunRequest.APIKey. Raw API keys do
|
||||||
|
// not belong in this config.
|
||||||
|
type OpenAICompatibleProfileConfig struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
Model string
|
||||||
|
APIKeyRequired bool
|
||||||
|
Temperature float64
|
||||||
|
MaxTokens int
|
||||||
|
TopP float64
|
||||||
|
TimeoutSeconds int
|
||||||
|
ServiceTier string
|
||||||
|
ReasoningEffort 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 `json:"status"`
|
||||||
|
Mode ValidationMode `json:"mode"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
SchemaPath string `json:"schema_path,omitempty"`
|
||||||
|
RepairAttempts int `json:"repair_attempts"`
|
||||||
|
IsValid bool `json:"is_valid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsage tracks token consumption.
|
||||||
|
type TokenUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedPrompt is the fully rendered prompt passed to an LLM client.
|
||||||
|
type RenderedPrompt struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClient executes rendered prompts for Engine.Run.
|
||||||
|
type LLMClient interface {
|
||||||
|
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRequest is passed to an injected LLM client.
|
||||||
|
type GenerateRequest struct {
|
||||||
|
Prompt RenderedPrompt `json:"prompt"`
|
||||||
|
Target ExecutionTarget `json:"target"`
|
||||||
|
TargetPresence ExecutionTargetPresence `json:"target_presence"`
|
||||||
|
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
||||||
|
APIKey string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateResponse is returned by an injected LLM client.
|
||||||
|
type GenerateResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Usage TokenUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user