From bc5a2da0f291db18969d952b893ea0f8712e469e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 8 Oct 2026 20:40:16 +0000 Subject: [PATCH] Expose prompt requirement inspection --- doc.go | 17 +- docs/internal/overview.md | 4 +- docs/internal/runner.md | 9 + docs/roadmap/implementation.md | 4 + engine.go | 6 +- internal/engine/engine.go | 6 + internal/usecase/requirements_inspection.go | 79 ++++++ .../usecase/requirements_inspection_test.go | 230 ++++++++++++++++++ requirements.go | 158 ++++++++++++ requirements_test.go | 15 ++ 10 files changed, 515 insertions(+), 13 deletions(-) create mode 100644 internal/usecase/requirements_inspection.go create mode 100644 internal/usecase/requirements_inspection_test.go create mode 100644 requirements.go create mode 100644 requirements_test.go diff --git a/doc.go b/doc.go index 5445b11..13aea28 100644 --- a/doc.go +++ b/doc.go @@ -4,7 +4,7 @@ // Applications construct an [Engine] with [NewEngine], select ConfigKit // sources for prompts, profiles, and backends, and // call [Engine.InspectConfiguration], [Engine.InspectPrompt], -// [Engine.InspectProfile], [Engine.Prepare], +// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], // [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete // registries, repositories, validators, and the built-in OpenAI-compatible // client remain internal implementation details. @@ -12,7 +12,7 @@ // # Concurrency and ownership // // An Engine supports concurrent InspectConfiguration, InspectPrompt, -// InspectProfile, Prepare, +// InspectRequirements, InspectProfile, Prepare, // PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies // bound admitted Run and RunPrepared calls and model generations where // configured, while different backend pools and unlimited backends continue @@ -20,11 +20,11 @@ // still receive concurrent calls and must be safe for that use. // // ConfigKit captures the selected configuration when NewEngine constructs an -// Engine. Prepare, -// PrepareExecution, and Run copy request maps, slices, pointer values, and -// JSON-compatible extra parameters before using them. InspectPrompt and -// InspectProfile return copied inspection values. Returned values and values -// passed to extension interfaces are likewise isolated from engine state. +// Engine. Prepare, PrepareExecution, and Run copy request maps, slices, pointer +// values, and JSON-compatible extra parameters before using them. InspectPrompt, +// InspectRequirements, and InspectProfile return copied inspection values. +// Returned values and values passed to extension interfaces are likewise +// isolated from engine state. // Callers own those copies and may mutate them after the call that supplied or // returned them. [CapacityError] values are caller-owned and may be mutated // without affecting engine state or another error. Immutable [GenerationError] @@ -58,7 +58,8 @@ // [ConfigurationSources], [ConfigurationInspection], [BackendConfig], // [ProfileConfig], [RunRequest], // [ArtifactRef], [ExecutionTargetOverride], [ProfileInspection], -// [PromptInputDefinition], [PromptInspection], [PreparedExecution], +// [PromptInputDefinition], [PromptInspection], [RequirementInspectionRequest], +// [RequirementInspection], [PreparedExecution], // [CapacityError], [GenerationError], [RetryExhaustedError], and // [ExecutionEvent], do not have stable JSON // representations. Direct API keys are nevertheless excluded from JSON for diff --git a/docs/internal/overview.md b/docs/internal/overview.md index cb7a78e..6585c57 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -11,7 +11,7 @@ contributor workflow and validation. | Component | Implemented responsibility | References | | --- | --- | --- | -| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, profile and backend mapping conveniences, injection options, public requests and results, explicit inspection, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go) | +| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, profile and backend mapping conveniences, injection options, public requests and results, prompt/profile/requirement inspection, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [requirement facade](../../requirements.go) | | `examples/go-library/prepare` | Demonstrates offline preparation and explicit configuration inspection with a prompt file, programmatic profile, and inline input. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) | | `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) | | `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) | @@ -32,7 +32,7 @@ contributor workflow and validation. | `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) | | `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) | | `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts. | [Internal execution observation](observation.md) | -| `internal/usecase` | Selects captured prompts and profiles, applies request overrides and completion checks, and coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) | +| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, and coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) | The internal engine assembles these internal components without exposing their representations. Consumers depend only on the root facade. diff --git a/docs/internal/runner.md b/docs/internal/runner.md index 26e6eab..1797bf1 100644 --- a/docs/internal/runner.md +++ b/docs/internal/runner.md @@ -43,6 +43,15 @@ touch profile, artifact, schema, renderer, validator, admission, or model collaborators. The root [`Engine.InspectPrompt`](../../engine.go) GoDoc owns the public operation's exact contract. +`InspectRequirements` uses the same prompt selection boundary, then selects the +request profile or prompt default for identity and obtains the exact captured +requirement snapshot. It analyzes that snapshot with the supplied caller +variables and normalized direct-session bypass; it does not resolve target +readiness or enter the preparation pipeline. Missing snapshot capability is a +prompt-load failure rather than a source fallback. The root +[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public +operation's exact contract. + ## Shared Profile Selection The runner uses one profile-selection and target-resolution boundary for diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index da224b4..46e7c99 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -340,6 +340,10 @@ in results/explanations. Run `go test ./internal/prompt`; commit. ## Stage 5 — Wire Selection And The Public Facade +**Status: Complete (2026-10-08).** The root API now selects captured prompt and +profile identities, analyzes the exact captured snapshot, and returns +caller-owned requirement values without entering preparation or execution. + **Outcome:** Consumers can inspect a selected captured prompt before artifacts exist, without execution collaborators or readiness checks. diff --git a/engine.go b/engine.go index ecc9da2..91620a1 100644 --- a/engine.go +++ b/engine.go @@ -82,12 +82,12 @@ var ( ErrValidation = errors.New("failed to validate output") ) -// Engine inspects prompts and profiles and prepares and runs Promptkit prompt -// requests. +// Engine inspects prompts and profiles, enumerates prompt requirements, and +// prepares and runs Promptkit prompt requests. // // An Engine is safe for concurrent calls to [Engine.InspectConfiguration], // [Engine.InspectPrompt], -// [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], +// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent // backend-capacity pools that coordinate Run and RunPrepared admission and // model generation. Injected collaborators may still be invoked concurrently diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 7f37ced..655d354 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -178,6 +178,12 @@ func fileSource(name string) (fs.FS, string, string, error) { func (e *Engine) InspectPrompt(ctx context.Context, id, version string) (*domain.PromptInspection, error) { return e.runner.InspectPrompt(ctx, id, version) } +func (e *Engine) InspectRequirements( + ctx context.Context, + request usecase.RequirementInspectionRequest, +) (*domain.RequirementInspection, error) { + return e.runner.InspectRequirements(ctx, request) +} func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) { return e.runner.InspectProfile(ctx, id) } diff --git a/internal/usecase/requirements_inspection.go b/internal/usecase/requirements_inspection.go new file mode 100644 index 0000000..be9d9fb --- /dev/null +++ b/internal/usecase/requirements_inspection.go @@ -0,0 +1,79 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/prompt" +) + +// RequirementInspectionRequest selects a captured prompt and supplies the +// context used for dependency classification. +type RequirementInspectionRequest struct { + PromptID string + PromptVersion string + ProfileID string + Vars map[string]string + SessionID string +} + +type requirementSnapshotRepository interface { + GetRequirementSnapshot(id, version string) (prompt.RequirementSnapshot, bool) +} + +// InspectRequirements discovers prompt inputs and caller variables without +// loading artifacts or performing execution preparation. +func (r *Runner) InspectRequirements( + ctx context.Context, + req RequirementInspectionRequest, +) (*domain.RequirementInspection, error) { + if strings.TrimSpace(req.PromptID) == "" { + return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest) + } + directSessionID, err := domain.NormalizeSessionID(req.SessionID) + if err != nil { + return nil, fmt.Errorf("%w: session_id: %v", ErrInvalidRequest, err) + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + } + + selection, err := r.resolvePromptDefinition(ctx, req.PromptID, req.PromptVersion) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) + } + selectedProfileID := req.ProfileID + if strings.TrimSpace(selectedProfileID) == "" { + selectedProfileID = selection.definition.DefaultProfile + } + if strings.TrimSpace(selectedProfileID) == "" { + return nil, fmt.Errorf("%w: %w: profile id is required either in request or prompt default_profile", ErrInvalidRequest, ErrProfileRequired) + } + profileSelection, err := r.resolveProfileSelection(ctx, selectedProfileID) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + } + + repository, ok := r.promptDefs.(requirementSnapshotRepository) + if !ok { + return nil, fmt.Errorf("%w: prompt repository does not provide captured requirement snapshots", ErrPromptLoad) + } + snapshot, ok := repository.GetRequirementSnapshot(selection.definition.ID, selection.definition.Version) + if !ok { + return nil, fmt.Errorf("%w: captured requirement snapshot is unavailable", ErrPromptLoad) + } + inspection, err := prompt.AnalyzeRequirements(ctx, selection.definition, snapshot, req.Vars, directSessionID != "") + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + } + inspection.ProfileID = profileSelection.id + return &inspection, nil +} diff --git a/internal/usecase/requirements_inspection_test.go b/internal/usecase/requirements_inspection_test.go new file mode 100644 index 0000000..9ac438d --- /dev/null +++ b/internal/usecase/requirements_inspection_test.go @@ -0,0 +1,230 @@ +package usecase + +import ( + "context" + "errors" + "testing" + "text/template" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/prompt" + "gitea.maximumdirect.net/eric/promptkit/internal/promptdef" +) + +type requirementInspectionPromptRepository struct { + definition *domain.PromptDefinition + snapshot prompt.RequirementSnapshot + lookupErr error + snapshotFound bool + lookupCalls int + snapshotCalls int + lookupID string + lookupVersion string + snapshotID string + snapshotVersion string +} + +func (repository *requirementInspectionPromptRepository) GetPromptDefinition( + context.Context, + string, + string, +) (*domain.PromptDefinition, error) { + return repository.definition, repository.lookupErr +} + +func (repository *requirementInspectionPromptRepository) GetPromptSnapshot( + _ context.Context, + id string, + version string, +) (*domain.PromptDefinition, string, error) { + repository.lookupCalls++ + repository.lookupID = id + repository.lookupVersion = version + return repository.definition, "captured-hash", repository.lookupErr +} + +func (repository *requirementInspectionPromptRepository) GetRequirementSnapshot( + id string, + version string, +) (prompt.RequirementSnapshot, bool) { + repository.snapshotCalls++ + repository.snapshotID = id + repository.snapshotVersion = version + return repository.snapshot, repository.snapshotFound +} + +func TestRunnerInspectRequirementsUsesCapturedSelectionWithoutExecutionWork(t *testing.T) { + message, err := prompt.CompileTemplate("message", `{{input "message-input"}} {{.caller}}`) + if err != nil { + t.Fatal(err) + } + session, err := prompt.CompileTemplate("session", `{{input "session-only"}}`) + if err != nil { + t.Fatal(err) + } + definition := &domain.PromptDefinition{ID: "selected", Version: "v2", DefaultProfile: "default-profile"} + repository := &requirementInspectionPromptRepository{ + definition: definition, + snapshotFound: true, + snapshot: prompt.RequirementSnapshot{Templates: prompt.CompiledTemplates{ + Messages: []*template.Template{message}, Session: session, + }}, + } + profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{ + ID: "partial-profile", ResolvedTarget: &domain.ExecutionTarget{Endpoint: ""}, + }} + runner := &Runner{promptDefs: repository, profiles: profiles} + inspection, err := runner.InspectRequirements(context.Background(), RequirementInspectionRequest{ + PromptID: " requested ", PromptVersion: " exact-version ", ProfileID: "partial-profile", + Vars: map[string]string{"caller": ""}, SessionID: " direct-session ", + }) + if err != nil { + t.Fatalf("inspect requirements: %v", err) + } + if repository.lookupCalls != 1 || repository.lookupID != " requested " || repository.lookupVersion != " exact-version " || + repository.snapshotCalls != 1 || repository.snapshotID != definition.ID || repository.snapshotVersion != definition.Version { + t.Fatalf("selection lookup=(%d,%q,%q), snapshot=(%d,%q,%q)", repository.lookupCalls, repository.lookupID, + repository.lookupVersion, repository.snapshotCalls, repository.snapshotID, repository.snapshotVersion) + } + if profiles.calls != 1 || profiles.id != "partial-profile" || inspection.PromptID != definition.ID || + inspection.PromptVersion != definition.Version || inspection.ProfileID != "partial-profile" || !inspection.Complete || + len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "message-input" || len(inspection.Variables) != 1 || + inspection.Variables[0].Name != "caller" || !inspection.Variables[0].Supplied { + t.Fatalf("unexpected requirement inspection: %+v", inspection) + } +} + +func TestRunnerInspectRequirementsUsesPromptDefaultAndIncludesSessionWhenNotBypassed(t *testing.T) { + message, err := prompt.CompileTemplate("message", `{{input "message-input"}}`) + if err != nil { + t.Fatal(err) + } + session, err := prompt.CompileTemplate("session", `{{input "session-input"}}`) + if err != nil { + t.Fatal(err) + } + definition := &domain.PromptDefinition{ID: "selected", Version: "v1", DefaultProfile: "default-profile"} + repository := &requirementInspectionPromptRepository{ + definition: definition, snapshotFound: true, + snapshot: prompt.RequirementSnapshot{Templates: prompt.CompiledTemplates{Messages: []*template.Template{message}, Session: session}}, + } + profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{ + ID: "default-profile", ResolvedTarget: &domain.ExecutionTarget{}, + }} + inspection, err := (&Runner{promptDefs: repository, profiles: profiles}).InspectRequirements( + context.Background(), RequirementInspectionRequest{PromptID: "selected"}, + ) + if err != nil { + t.Fatalf("inspect requirements: %v", err) + } + if profiles.id != "default-profile" || inspection.ProfileID != "default-profile" || len(inspection.Inputs) != 2 || + inspection.Inputs[0].Name != "message-input" || inspection.Inputs[1].Name != "session-input" { + t.Fatalf("prompt-default profile or session template was not selected: %+v", inspection) + } +} + +func TestRunnerInspectRequirementsReturnsIncompleteDiscoveryAsSuccess(t *testing.T) { + compiled, err := prompt.CompileTemplate("message", `{{if .missing}}{{input "left"}}{{else}}{{input "right"}}{{end}}`) + if err != nil { + t.Fatal(err) + } + repository := &requirementInspectionPromptRepository{ + definition: &domain.PromptDefinition{ID: "selected", Version: "v1", DefaultProfile: "profile"}, + snapshotFound: true, + snapshot: prompt.RequirementSnapshot{Templates: prompt.CompiledTemplates{Messages: []*template.Template{compiled}}}, + } + profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{ID: "profile", ResolvedTarget: &domain.ExecutionTarget{}}} + inspection, err := (&Runner{promptDefs: repository, profiles: profiles}).InspectRequirements( + context.Background(), RequirementInspectionRequest{PromptID: "selected"}, + ) + if err != nil || inspection == nil || inspection.Complete || len(inspection.Issues) != 1 || + inspection.Issues[0].Code != domain.RequirementIssueMissingVariable { + t.Fatalf("incomplete discovery returned (%+v, %v), want successful partial result", inspection, err) + } +} + +func TestRunnerInspectRequirementsClassifiesSelectionAndSnapshotFailures(t *testing.T) { + t.Run("blank prompt", func(t *testing.T) { + repository := &requirementInspectionPromptRepository{} + _, err := (&Runner{promptDefs: repository}).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: " \t "}) + if !errors.Is(err, ErrInvalidRequest) || repository.lookupCalls != 0 { + t.Fatalf("blank prompt error=%v lookups=%d", err, repository.lookupCalls) + } + }) + + t.Run("invalid session", func(t *testing.T) { + repository := &requirementInspectionPromptRepository{} + _, err := (&Runner{promptDefs: repository}).InspectRequirements(context.Background(), RequirementInspectionRequest{ + PromptID: "selected", SessionID: string([]byte{0xff}), + }) + if !errors.Is(err, ErrInvalidRequest) || repository.lookupCalls != 0 { + t.Fatalf("invalid session error=%v lookups=%d", err, repository.lookupCalls) + } + }) + + t.Run("cancellation before lookup", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + repository := &requirementInspectionPromptRepository{} + _, err := (&Runner{promptDefs: repository}).InspectRequirements(ctx, RequirementInspectionRequest{PromptID: "selected"}) + if !errors.Is(err, ErrPromptLoad) || !errors.Is(err, context.Canceled) || repository.lookupCalls != 0 { + t.Fatalf("canceled inspection=%v lookups=%d", err, repository.lookupCalls) + } + }) + + t.Run("no profile selection", func(t *testing.T) { + repository := &requirementInspectionPromptRepository{definition: &domain.PromptDefinition{ID: "selected"}} + _, err := (&Runner{promptDefs: repository}).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}) + if !errors.Is(err, ErrInvalidRequest) || !errors.Is(err, ErrProfileRequired) { + t.Fatalf("missing profile error=%v", err) + } + }) + + t.Run("ambiguous prompt selection", func(t *testing.T) { + ambiguous := errors.New("ambiguous omitted version") + repository := &requirementInspectionPromptRepository{lookupErr: ambiguous} + _, err := (&Runner{promptDefs: repository}).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}) + if !errors.Is(err, ErrPromptLoad) || !errors.Is(err, ambiguous) || repository.lookupVersion != "" { + t.Fatalf("ambiguous selection error=%v version=%q", err, repository.lookupVersion) + } + }) + + t.Run("captured snapshot unavailable", func(t *testing.T) { + repository := &requirementInspectionPromptRepository{definition: &domain.PromptDefinition{ID: "selected", DefaultProfile: "profile"}} + profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{ID: "profile", ResolvedTarget: &domain.ExecutionTarget{}}} + _, err := (&Runner{promptDefs: repository, profiles: profiles}).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}) + if !errors.Is(err, ErrPromptLoad) || repository.snapshotCalls != 1 { + t.Fatalf("missing snapshot error=%v calls=%d", err, repository.snapshotCalls) + } + }) + + t.Run("repository lacks snapshot capability", func(t *testing.T) { + _, err := (&Runner{promptDefs: &inspectionPromptRepository{definition: &domain.PromptDefinition{ID: "selected", DefaultProfile: "profile"}}, + profiles: &inspectionProfileRepository{profile: &domain.ExecutionProfile{ID: "profile", ResolvedTarget: &domain.ExecutionTarget{}}}, + }).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}) + if !errors.Is(err, ErrPromptLoad) { + t.Fatalf("missing capability error=%v", err) + } + }) + if _, err := (*Runner)(nil).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}); !errors.Is(err, ErrPromptLoad) { + t.Fatalf("nil runner error=%v", err) + } + if _, err := (&Runner{}).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}); !errors.Is(err, ErrPromptLoad) { + t.Fatalf("nil repository error=%v", err) + } + if _, err := (&Runner{promptDefs: &requirementInspectionPromptRepository{definition: &domain.PromptDefinition{ID: "selected"}}}).InspectRequirements( + context.Background(), RequirementInspectionRequest{PromptID: "selected", ProfileID: "explicit"}, + ); !errors.Is(err, ErrProfileLoad) { + t.Fatalf("missing profile error=%v", err) + } + if _, err := (&Runner{promptDefs: &requirementInspectionPromptRepository{definition: &domain.PromptDefinition{ID: "selected", DefaultProfile: "profile"}}, + profiles: &inspectionProfileRepository{profile: &domain.ExecutionProfile{ID: "profile"}}, + }).InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "selected"}); !errors.Is(err, ErrProfileLoad) { + t.Fatalf("unresolved profile error=%v", err) + } + if _, err := (&Runner{promptDefs: &requirementInspectionPromptRepository{lookupErr: promptdef.ErrPromptDefinitionNotFound}}).InspectRequirements( + context.Background(), RequirementInspectionRequest{PromptID: "missing"}, + ); !errors.Is(err, ErrPromptLoad) || !errors.Is(err, promptdef.ErrPromptDefinitionNotFound) { + t.Fatalf("missing prompt error=%v", err) + } +} diff --git a/requirements.go b/requirements.go new file mode 100644 index 0000000..f78a3f3 --- /dev/null +++ b/requirements.go @@ -0,0 +1,158 @@ +package promptkit + +import ( + "context" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/usecase" +) + +// RequirementInspectionRequest selects a prompt and supplies caller context +// for requirement discovery. It requires no artifact bindings or credentials. +type RequirementInspectionRequest struct { + PromptID string + PromptVersion string + ProfileID string + Vars map[string]string + SessionID string +} + +// RequirementKind describes how a selected prompt may depend on an input or +// caller variable for the supplied context. +type RequirementKind string + +const ( + // RequirementRequired identifies a declaration or definite access. + RequirementRequired RequirementKind = "required" + // RequirementConditional identifies a dependency whose reachability is + // unresolved for the supplied context. + RequirementConditional RequirementKind = "conditional" + // RequirementOptional identifies a dependency known not to be required, + // including a reference used only on a known skipped path. + RequirementOptional RequirementKind = "optional" +) + +// InputRequirement describes a declared or template-referenced prompt input. +type InputRequirement struct { + Name string + Kind RequirementKind + Declared bool + ContentType *string + Description *string + Explanation string +} + +// VariableRequirement describes a caller-map key referenced by a prompt. +type VariableRequirement struct { + Name string + Kind RequirementKind + Supplied bool + Explanation string +} + +// RequirementIssueCode identifies a limitation that can affect discovery. +type RequirementIssueCode string + +const ( + RequirementIssueMissingVariable RequirementIssueCode = "missing_variable" + RequirementIssueInputContent RequirementIssueCode = "input_content" + RequirementIssueDynamicInputName RequirementIssueCode = "dynamic_input_name" + RequirementIssueUnsupportedConstruct RequirementIssueCode = "unsupported_construct" +) + +// RequirementIssue describes one discovery limitation using safe summary +// text. Descriptions are not intended as machine-readable details. +type RequirementIssue struct { + Code RequirementIssueCode + InputName string + VariableName string + Description string +} + +// RequirementInspection contains discovered dependencies for one selected +// prompt context. Complete describes discovery, not renderability or readiness. +type RequirementInspection struct { + PromptID string + PromptVersion string + ProfileID string + Inputs []InputRequirement + Variables []VariableRequirement + Complete bool + Issues []RequirementIssue +} + +// InspectRequirements discovers inputs and caller variables for one selected +// prompt before its input artifacts exist. It uses exact, case-sensitive +// prompt selection and requires a profile ID either in the request or the +// selected prompt's default. The selected profile establishes identity only; +// it need not be execution-ready. A normalized nonblank SessionID bypasses the +// captured session template. Vars are copied before analysis, and returned +// slices and metadata pointers belong to the caller. +// +// This method does not perform artifact, schema, credential, rendering, +// validation, capacity, observation, provider, or execution work. Complete +// describes dependency discovery, not whether every dependency is supplied or +// the prompt is ready to execute. Discovery limitations return a partial +// inspection and no error; selection and operational failures return no +// inspection. Blank prompt IDs, invalid session IDs, and absent profile +// selection match ErrInvalidRequest; an absent profile selection also matches +// ErrProfileRequired. Missing prompt/profile IDs match ErrPromptNotFound or +// ErrProfileNotFound. Other selection/snapshot failures match ErrPromptLoad or +// ErrProfileLoad. A nil Engine matches ErrInvalidConfig. +func (e *Engine) InspectRequirements( + ctx context.Context, + req RequirementInspectionRequest, +) (*RequirementInspection, error) { + if e == nil || e.internal == nil { + return nil, ErrInvalidConfig + } + inspection, err := e.internal.InspectRequirements(ctx, usecase.RequirementInspectionRequest{ + PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, + Vars: copyStringMap(req.Vars), SessionID: req.SessionID, + }) + if err != nil { + return nil, mapPublicError(err) + } + return fromDomainRequirementInspection(inspection), nil +} + +func fromDomainRequirementInspection(inspection *domain.RequirementInspection) *RequirementInspection { + if inspection == nil { + return nil + } + result := &RequirementInspection{ + PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, + ProfileID: inspection.ProfileID, Complete: inspection.Complete, + Inputs: make([]InputRequirement, len(inspection.Inputs)), + Variables: make([]VariableRequirement, len(inspection.Variables)), + Issues: make([]RequirementIssue, len(inspection.Issues)), + } + for index, input := range inspection.Inputs { + result.Inputs[index] = InputRequirement{ + Name: input.Name, Kind: RequirementKind(input.Kind), Declared: input.Declared, + ContentType: copyRequirementString(input.ContentType), Description: copyRequirementString(input.Description), + Explanation: input.Explanation, + } + } + for index, variable := range inspection.Variables { + result.Variables[index] = VariableRequirement{ + Name: variable.Name, Kind: RequirementKind(variable.Kind), Supplied: variable.Supplied, + Explanation: variable.Explanation, + } + } + for index, issue := range inspection.Issues { + result.Issues[index] = RequirementIssue{ + Code: RequirementIssueCode(issue.Code), InputName: issue.InputName, + VariableName: issue.VariableName, Description: issue.Description, + } + } + return result +} + +func copyRequirementString(value *string) *string { + if value == nil { + return nil + } + copy := *value + return © +} diff --git a/requirements_test.go b/requirements_test.go new file mode 100644 index 0000000..2293852 --- /dev/null +++ b/requirements_test.go @@ -0,0 +1,15 @@ +package promptkit + +import ( + "context" + "errors" + "testing" +) + +func TestInspectRequirementsRejectsNilEngine(t *testing.T) { + var engine *Engine + inspection, err := engine.InspectRequirements(context.Background(), RequirementInspectionRequest{PromptID: "prompt"}) + if inspection != nil || !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("nil engine returned (%+v, %v), want ErrInvalidConfig", inspection, err) + } +}